diff --git a/Avalonia.sln.DotSettings b/Avalonia.sln.DotSettings index 25d62b0494..2c0a6b9dc8 100644 --- a/Avalonia.sln.DotSettings +++ b/Avalonia.sln.DotSettings @@ -38,4 +38,5 @@ <Policy Inspect="False" Prefix="T" Suffix="" Style="AaBb" /> <Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /> True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/samples/ControlCatalog.NetCore/Program.cs b/samples/ControlCatalog.NetCore/Program.cs index 1dc8c09c0e..ad705c66ea 100644 --- a/samples/ControlCatalog.NetCore/Program.cs +++ b/samples/ControlCatalog.NetCore/Program.cs @@ -109,7 +109,8 @@ namespace ControlCatalog.NetCore .With(new X11PlatformOptions { EnableMultiTouch = true, - UseDBusMenu = true + UseDBusMenu = true,OverlayPopups = true, + EnableIme = true }) .With(new Win32PlatformOptions { @@ -117,7 +118,7 @@ namespace ControlCatalog.NetCore AllowEglInitialization = true }) .UseSkia() - .UseManagedSystemDialogs() + //.UseManagedSystemDialogs() .LogToTrace(); static void SilenceConsole() diff --git a/samples/ControlCatalog/Assets/Fonts/WenQuanYiMicroHei-01.ttf b/samples/ControlCatalog/Assets/Fonts/WenQuanYiMicroHei-01.ttf new file mode 100644 index 0000000000..61e2583a6c Binary files /dev/null and b/samples/ControlCatalog/Assets/Fonts/WenQuanYiMicroHei-01.ttf differ diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index f001425964..423e4c77eb 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -11,6 +11,7 @@ + diff --git a/samples/ControlCatalog/Pages/TextBoxPage.xaml b/samples/ControlCatalog/Pages/TextBoxPage.xaml index 4958174f40..c5226f3e58 100644 --- a/samples/ControlCatalog/Pages/TextBoxPage.xaml +++ b/samples/ControlCatalog/Pages/TextBoxPage.xaml @@ -5,6 +5,9 @@ + + diff --git a/src/Avalonia.Controls/Platform/ITopLevelImplWithTextInputMethod.cs b/src/Avalonia.Controls/Platform/ITopLevelImplWithTextInputMethod.cs new file mode 100644 index 0000000000..9c29415a6a --- /dev/null +++ b/src/Avalonia.Controls/Platform/ITopLevelImplWithTextInputMethod.cs @@ -0,0 +1,11 @@ +using Avalonia.Input; +using Avalonia.Input.TextInput; +using Avalonia.Platform; + +namespace Avalonia.Controls.Platform +{ + public interface ITopLevelImplWithTextInputMethod : ITopLevelImpl + { + public ITextInputMethodImpl TextInputMethod { get; } + } +} diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 078d8050bf..6bbb1c13bf 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -1,5 +1,6 @@ using System; using System.Reactive.Linq; +using Avalonia.Input.TextInput; using Avalonia.Media; using Avalonia.Metadata; using Avalonia.Threading; @@ -378,19 +379,23 @@ namespace Avalonia.Controls.Presenters if (_caretBlink) { - var charPos = FormattedText.HitTestTextPosition(CaretIndex); - var x = Math.Floor(charPos.X) + 0.5; - var y = Math.Floor(charPos.Y) + 0.5; - var b = Math.Ceiling(charPos.Bottom) - 0.5; - + var (p1, p2) = GetCaretPoints(); context.DrawLine( new Pen(caretBrush, 1), - new Point(x, y), - new Point(x, b)); + p1, p2); } } } + (Point, Point) GetCaretPoints() + { + var charPos = FormattedText.HitTestTextPosition(CaretIndex); + var x = Math.Floor(charPos.X) + 0.5; + var y = Math.Floor(charPos.Y) + 0.5; + var b = Math.Ceiling(charPos.Bottom) - 0.5; + return (new Point(x, y), new Point(x, b)); + } + public void ShowCaret() { _caretBlink = true; @@ -538,5 +543,11 @@ namespace Avalonia.Controls.Presenters _caretBlink = !_caretBlink; InvalidateVisual(); } + + internal Rect GetCursorRectangle() + { + var (p1, p2) = GetCaretPoints(); + return new Rect(p1, p2); + } } } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 0fe3ac62e4..e96efa7ce2 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -149,6 +149,7 @@ namespace Avalonia.Controls private int _selectionStart; private int _selectionEnd; private TextPresenter _presenter; + private TextBoxTextInputMethodClient _imClient = new TextBoxTextInputMethodClient(); private UndoRedoHelper _undoRedoHelper; private bool _isUndoingRedoing; private bool _ignoreTextChanges; @@ -161,6 +162,10 @@ namespace Avalonia.Controls static TextBox() { FocusableProperty.OverrideDefaultValue(typeof(TextBox), true); + TextInputMethodClientRequestedEvent.AddClassHandler((tb, e) => + { + e.Client = tb._imClient; + }); } public TextBox() @@ -437,7 +442,7 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { _presenter = e.NameScope.Get("PART_TextPresenter"); - + _imClient.SetPresenter(_presenter); if (IsFocused) { _presenter?.ShowCaret(); diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs new file mode 100644 index 0000000000..ea664cecdc --- /dev/null +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -0,0 +1,33 @@ +using System; +using Avalonia.Controls.Presenters; +using Avalonia.Input.TextInput; +using Avalonia.VisualTree; + +namespace Avalonia.Controls +{ + internal class TextBoxTextInputMethodClient : ITextInputMethodClient + { + private TextPresenter _presenter; + private IDisposable _subscription; + public Rect CursorRectangle => _presenter?.GetCursorRectangle() ?? default; + public event EventHandler CursorRectangleChanged; + public IVisual TextViewVisual => _presenter; + public event EventHandler TextViewVisualChanged; + + private void OnCaretIndexChanged(int index) => CursorRectangleChanged?.Invoke(this, EventArgs.Empty); + + public void SetPresenter(TextPresenter presenter) + { + _subscription?.Dispose(); + _subscription = null; + _presenter = presenter; + if (_presenter != null) + { + _subscription = _presenter.GetObservable(TextPresenter.CaretIndexProperty) + .Subscribe(OnCaretIndexChanged); + } + TextViewVisualChanged?.Invoke(this, EventArgs.Empty); + CursorRectangleChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 3d24f60463..4e43ce13b7 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -1,8 +1,10 @@ using System; using System.Reactive.Linq; +using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; using Avalonia.Layout; using Avalonia.Logging; using Avalonia.LogicalTree; @@ -31,6 +33,7 @@ namespace Avalonia.Controls ICloseable, IStyleHost, ILogicalRoot, + ITextInputMethodRoot, IWeakSubscriber { /// @@ -489,5 +492,8 @@ namespace Avalonia.Controls if (focused == this) KeyboardDevice.Instance.SetFocusedElement(null, NavigationMethod.Unspecified, KeyModifiers.None); } + + ITextInputMethodImpl ITextInputMethodRoot.InputMethod => + (PlatformImpl as ITopLevelImplWithTextInputMethod)?.TextInputMethod; } } diff --git a/src/Avalonia.FreeDesktop/DBusCallQueue.cs b/src/Avalonia.FreeDesktop/DBusCallQueue.cs new file mode 100644 index 0000000000..fbe9df55b6 --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusCallQueue.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Avalonia.FreeDesktop +{ + class DBusCallQueue + { + class Item + { + public Func Callback; + public Func OnFinish; + } + private Queue _q = new Queue(); + private bool _processing; + + public void Enqueue(Func cb, Func onError) + { + _q.Enqueue(new Item + { + Callback = cb, + OnFinish = e => + { + if (e != null) + return onError?.Invoke(e); + return Task.CompletedTask; + } + }); + Process(); + } + + public Task EnqueueAsync(Func cb) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _q.Enqueue(new Item + { + Callback = cb, + OnFinish = e => + { + if (e == null) + tcs.TrySetResult(0); + else + tcs.TrySetException(e); + return Task.CompletedTask; + } + }); + Process(); + return tcs.Task; + } + + public Task EnqueueAsync(Func> cb) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _q.Enqueue(new Item + { + Callback = async () => + { + var res = await cb(); + tcs.TrySetResult(res); + }, + OnFinish = e => + { + if (e != null) + tcs.TrySetException(e); + return Task.CompletedTask; + } + }); + Process(); + return tcs.Task; + } + + async void Process() + { + if(_processing) + return; + _processing = true; + try + { + while (_q.Count > 0) + { + var item = _q.Dequeue(); + try + { + await item.Callback(); + await item.OnFinish(null); + } + catch(Exception e) + { + await item.OnFinish(e); + } + } + } + finally + { + _processing = false; + } + } + } +} diff --git a/src/Avalonia.FreeDesktop/DBusHelper.cs b/src/Avalonia.FreeDesktop/DBusHelper.cs index 91c4c28995..955d30626e 100644 --- a/src/Avalonia.FreeDesktop/DBusHelper.cs +++ b/src/Avalonia.FreeDesktop/DBusHelper.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using Avalonia.Logging; using Avalonia.Threading; using Tmds.DBus; @@ -48,8 +49,10 @@ namespace Avalonia.FreeDesktop } public static Connection Connection { get; private set; } - public static Exception TryInitialize(string dbusAddress = null) + public static Connection TryInitialize(string dbusAddress = null) { + if (Connection != null) + return Connection; var oldContext = SynchronizationContext.Current; try { @@ -70,13 +73,15 @@ namespace Avalonia.FreeDesktop } catch (Exception e) { - return e; + Logger.TryGet(LogEventLevel.Error, "DBUS") + ?.Log(null, "Unable to connect to DBus: " + e); } finally { SynchronizationContext.SetSynchronizationContext(oldContext); } - return null; + + return Connection; } } } diff --git a/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxDBus.cs b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxDBus.cs new file mode 100644 index 0000000000..4e5113b1ee --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxDBus.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Tmds.DBus; + +[assembly: InternalsVisibleTo(Tmds.DBus.Connection.DynamicAssemblyName)] +namespace Avalonia.FreeDesktop.DBusIme.Fcitx +{ + [DBusInterface("org.fcitx.Fcitx.InputMethod")] + interface IFcitxInputMethod : IDBusObject + { + Task<(int icid, uint keyval1, uint state1, uint keyval2, uint state2)> CreateICAsync(); + Task<(int icid, bool enable, uint keyval1, uint state1, uint keyval2, uint state2)> CreateICv2Async(string Appname); + Task<(int icid, bool enable, uint keyval1, uint state1, uint keyval2, uint state2)> CreateICv3Async(string Appname, int Pid); + Task ExitAsync(); + Task GetCurrentIMAsync(); + Task SetCurrentIMAsync(string Im); + Task ReloadConfigAsync(); + Task ReloadAddonConfigAsync(string Addon); + Task RestartAsync(); + Task ConfigureAsync(); + Task ConfigureAddonAsync(string Addon); + Task ConfigureIMAsync(string Im); + Task GetCurrentUIAsync(); + Task GetIMAddonAsync(string Im); + Task ActivateIMAsync(); + Task InactivateIMAsync(); + Task ToggleIMAsync(); + Task ResetIMListAsync(); + Task GetCurrentStateAsync(); + Task GetAsync(string prop); + Task GetAllAsync(); + Task SetAsync(string prop, object val); + Task WatchPropertiesAsync(Action handler); + } + + [Dictionary] + class FcitxInputMethodProperties + { + private (string, string, string, bool)[] _IMList = default((string, string, string, bool)[]); + public (string, string, string, bool)[] IMList + { + get + { + return _IMList; + } + + set + { + _IMList = (value); + } + } + + private string _CurrentIM = default(string); + public string CurrentIM + { + get + { + return _CurrentIM; + } + + set + { + _CurrentIM = (value); + } + } + } + + static class FcitxInputMethodExtensions + { + public static Task<(string, string, string, bool)[]> GetIMListAsync(this IFcitxInputMethod o) => o.GetAsync<(string, string, string, bool)[]>("IMList"); + public static Task GetCurrentIMAsync(this IFcitxInputMethod o) => o.GetAsync("CurrentIM"); + public static Task SetIMListAsync(this IFcitxInputMethod o, (string, string, string, bool)[] val) => o.SetAsync("IMList", val); + public static Task SetCurrentIMAsync(this IFcitxInputMethod o, string val) => o.SetAsync("CurrentIM", val); + } + + [DBusInterface("org.fcitx.Fcitx.InputContext")] + interface IFcitxInputContext : IDBusObject + { + Task EnableICAsync(); + Task CloseICAsync(); + Task FocusInAsync(); + Task FocusOutAsync(); + Task ResetAsync(); + Task MouseEventAsync(int X); + Task SetCursorLocationAsync(int X, int Y); + Task SetCursorRectAsync(int X, int Y, int W, int H); + Task SetCapacityAsync(uint Caps); + Task SetSurroundingTextAsync(string Text, uint Cursor, uint Anchor); + Task SetSurroundingTextPositionAsync(uint Cursor, uint Anchor); + Task DestroyICAsync(); + Task ProcessKeyEventAsync(uint Keyval, uint Keycode, uint State, int Type, uint Time); + Task WatchEnableIMAsync(Action handler, Action onError = null); + Task WatchCloseIMAsync(Action handler, Action onError = null); + Task WatchCommitStringAsync(Action handler, Action onError = null); + Task WatchCurrentIMAsync(Action<(string name, string uniqueName, string langCode)> handler, Action onError = null); + Task WatchUpdatePreeditAsync(Action<(string str, int cursorpos)> handler, Action onError = null); + Task WatchUpdateFormattedPreeditAsync(Action<((string, int)[] str, int cursorpos)> handler, Action onError = null); + Task WatchUpdateClientSideUIAsync(Action<(string auxup, string auxdown, string preedit, string candidateword, string imname, int cursorpos)> handler, Action onError = null); + Task WatchForwardKeyAsync(Action<(uint keyval, uint state, int type)> handler, Action onError = null); + Task WatchDeleteSurroundingTextAsync(Action<(int offset, uint nchar)> handler, Action onError = null); + } +} diff --git a/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxEnums.cs b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxEnums.cs new file mode 100644 index 0000000000..6510a5877a --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxEnums.cs @@ -0,0 +1,67 @@ +using System; + +namespace Avalonia.FreeDesktop.DBusIme.Fcitx +{ + enum FcitxKeyEventType + { + FCITX_PRESS_KEY, + FCITX_RELEASE_KEY + }; + + [Flags] + enum FcitxCapabilityFlags + { + CAPACITY_NONE = 0, + CAPACITY_CLIENT_SIDE_UI = (1 << 0), + CAPACITY_PREEDIT = (1 << 1), + CAPACITY_CLIENT_SIDE_CONTROL_STATE = (1 << 2), + CAPACITY_PASSWORD = (1 << 3), + CAPACITY_FORMATTED_PREEDIT = (1 << 4), + CAPACITY_CLIENT_UNFOCUS_COMMIT = (1 << 5), + CAPACITY_SURROUNDING_TEXT = (1 << 6), + CAPACITY_EMAIL = (1 << 7), + CAPACITY_DIGIT = (1 << 8), + CAPACITY_UPPERCASE = (1 << 9), + CAPACITY_LOWERCASE = (1 << 10), + CAPACITY_NOAUTOUPPERCASE = (1 << 11), + CAPACITY_URL = (1 << 12), + CAPACITY_DIALABLE = (1 << 13), + CAPACITY_NUMBER = (1 << 14), + CAPACITY_NO_ON_SCREEN_KEYBOARD = (1 << 15), + CAPACITY_SPELLCHECK = (1 << 16), + CAPACITY_NO_SPELLCHECK = (1 << 17), + CAPACITY_WORD_COMPLETION = (1 << 18), + CAPACITY_UPPERCASE_WORDS = (1 << 19), + CAPACITY_UPPERCASE_SENTENCES = (1 << 20), + CAPACITY_ALPHA = (1 << 21), + CAPACITY_NAME = (1 << 22), + CAPACITY_GET_IM_INFO_ON_FOCUS = (1 << 23), + CAPACITY_RELATIVE_CURSOR_RECT = (1 << 24), + }; + + [Flags] + enum FcitxKeyState + { + FcitxKeyState_None = 0, + FcitxKeyState_Shift = 1 << 0, + FcitxKeyState_CapsLock = 1 << 1, + FcitxKeyState_Ctrl = 1 << 2, + FcitxKeyState_Alt = 1 << 3, + FcitxKeyState_Alt_Shift = FcitxKeyState_Alt | FcitxKeyState_Shift, + FcitxKeyState_Ctrl_Shift = FcitxKeyState_Ctrl | FcitxKeyState_Shift, + FcitxKeyState_Ctrl_Alt = FcitxKeyState_Ctrl | FcitxKeyState_Alt, + + FcitxKeyState_Ctrl_Alt_Shift = + FcitxKeyState_Ctrl | FcitxKeyState_Alt | FcitxKeyState_Shift, + FcitxKeyState_NumLock = 1 << 4, + FcitxKeyState_Super = 1 << 6, + FcitxKeyState_ScrollLock = 1 << 7, + FcitxKeyState_MousePressed = 1 << 8, + FcitxKeyState_HandledMask = 1 << 24, + FcitxKeyState_IgnoredMask = 1 << 25, + FcitxKeyState_Super2 = 1 << 26, + FcitxKeyState_Hyper = 1 << 27, + FcitxKeyState_Meta = 1 << 28, + FcitxKeyState_UsedMask = 0x5c001fff + }; +} diff --git a/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs new file mode 100644 index 0000000000..60f44e4651 --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; +using Avalonia.Logging; +using Tmds.DBus; + +namespace Avalonia.FreeDesktop.DBusIme.Fcitx +{ + internal class FcitxIx11TextInputMethodFactory : IX11InputMethodFactory + { + private readonly Connection _connection; + + public FcitxIx11TextInputMethodFactory(Connection connection) + { + _connection = connection; + } + + public (ITextInputMethodImpl method, IX11InputMethodControl control) CreateClient(IntPtr xid) + { + var cl = new FcitxTextInputMethod(xid, _connection); + return (cl, cl); + } + } + + + internal class FcitxTextInputMethod : ITextInputMethodImpl, IX11InputMethodControl + { + private readonly IntPtr _xid; + private readonly Connection _connection; + private IFcitxInputContext _context; + private bool _connecting; + private string _currentName; + private DBusCallQueue _queue = new DBusCallQueue(); + private bool _controlActive, _windowActive, _imeActive; + private Rect _logicalRect; + private double _scaling = 1; + private PixelPoint _windowPosition; + private bool _disposed; + private PixelRect? _lastReportedRect; + private FcitxCapabilityFlags _lastReportedFlags; + + private List _disposables = new List(); + private List _subscriptions = new List(); + public FcitxTextInputMethod(IntPtr xid, Connection connection) + { + _xid = xid; + _connection = connection; + _disposables.Add(_connection.ResolveServiceOwnerAsync("org.fcitx.Fcitx", OnNameChange)); + } + + private async void OnNameChange(ServiceOwnerChangedEventArgs args) + { + if (args.NewOwner != null && _context == null && !_connecting) + { + _connecting = true; + try + { + var method = _connection.CreateProxy(args.ServiceName, "/inputmethod"); + var resp = await method.CreateICv3Async( + Application.Current.Name ?? Assembly.GetEntryAssembly()?.GetName()?.Name ?? "Avalonia", + Process.GetCurrentProcess().Id); + + _context = _connection.CreateProxy(args.ServiceName, + "/inputcontext_" + resp.icid); + _currentName = args.ServiceName; + _imeActive = false; + _lastReportedRect = null; + _lastReportedFlags = default; + _subscriptions.Add(await _context.WatchCommitStringAsync(OnCommitString)); + _subscriptions.Add(await _context.WatchForwardKeyAsync(OnForward)); + UpdateActive(); + UpdateCursorRect(); + + } + catch(Exception e) + { + Logger.TryGet(LogEventLevel.Error, "FCITX") + ?.Log(this, "Unable to create fcitx input context:\n" + e); + } + finally + { + _connecting = false; + } + + } + + // fcitx has crashed + if (args.NewOwner == null && args.ServiceName == _currentName) + { + _context = null; + _currentName = null; + _imeActive = false; + foreach(var s in _subscriptions) + s.Dispose(); + _subscriptions.Clear(); + } + } + + private void OnForward((uint keyval, uint state, int type) ev) + { + var state = (FcitxKeyState)ev.state; + KeyModifiers mods = default; + if (state.HasFlagCustom(FcitxKeyState.FcitxKeyState_Ctrl)) + mods |= KeyModifiers.Control; + if (state.HasFlagCustom(FcitxKeyState.FcitxKeyState_Alt)) + mods |= KeyModifiers.Alt; + if (state.HasFlagCustom(FcitxKeyState.FcitxKeyState_Shift)) + mods |= KeyModifiers.Shift; + if (state.HasFlagCustom(FcitxKeyState.FcitxKeyState_Super)) + mods |= KeyModifiers.Meta; + _onForward?.Invoke(new X11InputMethodForwardedKey + { + Modifiers = mods, + KeyVal = (int)ev.keyval, + Type = ev.type == (int)FcitxKeyEventType.FCITX_PRESS_KEY ? + RawKeyEventType.KeyDown : + RawKeyEventType.KeyUp + }); + } + + private void OnCommitString(string s) => _onCommit?.Invoke(s); + + async Task OnError(Exception e) + { + Logger.TryGet(LogEventLevel.Error, "FCITX") + ?.Log(this, "Error:\n" + e); + try + { + await _context.DestroyICAsync(); + } + catch (Exception ex) + { + Logger.TryGet(LogEventLevel.Error, "FCITX") + ?.Log(this, "Error while destroying the context:\n" + ex); + } + + _context = null; + _currentName = null; + _imeActive = false; + } + + void UpdateActive() + { + _queue.Enqueue(async () => + { + if(_context == null) + return; + + var active = _windowActive && _controlActive; + if (active != _imeActive) + { + _imeActive = active; + if (_imeActive) + await _context.FocusInAsync(); + else + await _context.FocusOutAsync(); + } + }, OnError); + } + + void UpdateCursorRect() + { + _queue.Enqueue(async () => + { + if(_context == null) + return; + var cursorRect = PixelRect.FromRect(_logicalRect, _scaling); + cursorRect = cursorRect.Translate(_windowPosition); + if (cursorRect != _lastReportedRect) + { + _lastReportedRect = cursorRect; + _context?.SetCursorRectAsync(cursorRect.X, cursorRect.Y, Math.Max(1, cursorRect.Width), + Math.Max(1, cursorRect.Height)); + } + }, OnError); + } + + public void SetOptions(TextInputOptionsQueryEventArgs options) + { + _queue.Enqueue(async () => + { + if(_context == null) + return; + FcitxCapabilityFlags flags = default; + if (options.Lowercase) + flags |= FcitxCapabilityFlags.CAPACITY_LOWERCASE; + if (options.Uppercase) + flags |= FcitxCapabilityFlags.CAPACITY_UPPERCASE; + if (!options.AutoCapitalization) + flags |= FcitxCapabilityFlags.CAPACITY_NOAUTOUPPERCASE; + if (options.ContentType == TextInputContentType.Email) + flags |= FcitxCapabilityFlags.CAPACITY_EMAIL; + else if (options.ContentType == TextInputContentType.Number) + flags |= FcitxCapabilityFlags.CAPACITY_NUMBER; + else if (options.ContentType == TextInputContentType.Password) + flags |= FcitxCapabilityFlags.CAPACITY_PASSWORD; + else if (options.ContentType == TextInputContentType.Phone) + flags |= FcitxCapabilityFlags.CAPACITY_DIALABLE; + else if (options.ContentType == TextInputContentType.Url) + flags |= FcitxCapabilityFlags.CAPACITY_URL; + if (flags != _lastReportedFlags) + { + _lastReportedFlags = flags; + await _context.SetCapacityAsync((uint)flags); + } + }, OnError); + } + + public void SetActive(bool active) + { + _controlActive = active; + UpdateActive(); + } + + void IX11InputMethodControl.SetWindowActive(bool active) + { + _windowActive = active; + UpdateActive(); + } + + bool IX11InputMethodControl.IsEnabled => _context != null && _controlActive; + + Task IX11InputMethodControl.HandleEventAsync(RawKeyEventArgs args, int keyVal, int keyCode) + { + return _queue.EnqueueAsync(async () => + { + if (_context == null) + return false; + FcitxKeyState state = default; + if (args.Modifiers.HasFlagCustom(RawInputModifiers.Control)) + state |= FcitxKeyState.FcitxKeyState_Ctrl; + if (args.Modifiers.HasFlagCustom(RawInputModifiers.Alt)) + state |= FcitxKeyState.FcitxKeyState_Alt; + if (args.Modifiers.HasFlagCustom(RawInputModifiers.Shift)) + state |= FcitxKeyState.FcitxKeyState_Shift; + if (args.Modifiers.HasFlagCustom(RawInputModifiers.Meta)) + state |= FcitxKeyState.FcitxKeyState_Super; + + var type = args.Type == RawKeyEventType.KeyDown ? + FcitxKeyEventType.FCITX_PRESS_KEY : + FcitxKeyEventType.FCITX_RELEASE_KEY; + + try + { + return await _context.ProcessKeyEventAsync((uint)keyVal, (uint)keyCode, (uint)state, (int)type, + (uint)args.Timestamp) != 0; + } + catch (Exception e) + { + await OnError(e); + return false; + } + }); + } + + private Action _onCommit; + event Action IX11InputMethodControl.OnCommit + { + add => _onCommit += value; + remove => _onCommit -= value; + } + + private Action _onForward; + event Action IX11InputMethodControl.OnForwardKey + { + add => _onForward += value; + remove => _onForward -= value; + } + + public void UpdateWindowInfo(PixelPoint position, double scaling) + { + _windowPosition = position; + _scaling = scaling; + UpdateCursorRect(); + } + + public void SetCursorRect(Rect rect) + { + _logicalRect = rect; + UpdateCursorRect(); + } + + + void IDisposable.Dispose() + { + _disposed = true; + foreach(var d in _disposables) + d.Dispose(); + _disposables.Clear(); + + foreach(var s in _subscriptions) + s.Dispose(); + _subscriptions.Clear(); + + // fire and forget + _context?.DestroyICAsync().ContinueWith(_ => { }); + _context = null; + _currentName = null; + } + } + +} diff --git a/src/Avalonia.FreeDesktop/DBusIme/X11DBusImeHelper.cs b/src/Avalonia.FreeDesktop/DBusIme/X11DBusImeHelper.cs new file mode 100644 index 0000000000..4ecfddf97b --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusIme/X11DBusImeHelper.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Avalonia.FreeDesktop.DBusIme.Fcitx; +using Tmds.DBus; + +namespace Avalonia.FreeDesktop.DBusIme +{ + public class X11DBusImeHelper + { + private static readonly Dictionary> KnownMethods = + new Dictionary> + { + ["fcitx"] = conn => new FcitxIx11TextInputMethodFactory(conn) + }; + + static bool IsCjkLocale(string lang) + { + if (lang == null) + return false; + return lang.Contains("zh") + || lang.Contains("ja") + || lang.Contains("vi") + || lang.Contains("ko"); + } + + static Func DetectInputMethod() + { + foreach (var name in new[] { "AVALONIA_IM_MODULE", "GTK_IM_MODULE", "QT_IM_MODULE" }) + { + var value = Environment.GetEnvironmentVariable(name); + if (value != null && KnownMethods.TryGetValue(value, out var factory)) + return factory; + } + + return null; + } + + public static void RegisterIfNeeded(bool? optionsWantIme) + { + if( + optionsWantIme == true + || Environment.GetEnvironmentVariable("AVALONIA_FORCE_IME") == "1" + || (optionsWantIme == null && IsCjkLocale(Environment.GetEnvironmentVariable("LANG")))) + { + var factory = DetectInputMethod(); + if (factory != null) + { + var conn = DBusHelper.TryInitialize(); + if (conn != null) + AvaloniaLocator.CurrentMutable.Bind().ToConstant(factory(conn)); + } + } + } + } +} diff --git a/src/Avalonia.FreeDesktop/IX11InputMethod.cs b/src/Avalonia.FreeDesktop/IX11InputMethod.cs new file mode 100644 index 0000000000..156e3611f4 --- /dev/null +++ b/src/Avalonia.FreeDesktop/IX11InputMethod.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; + +namespace Avalonia.FreeDesktop +{ + public interface IX11InputMethodFactory + { + (ITextInputMethodImpl method, IX11InputMethodControl control) CreateClient(IntPtr xid); + } + + public struct X11InputMethodForwardedKey + { + public int KeyVal { get; set; } + public KeyModifiers Modifiers { get; set; } + public RawKeyEventType Type { get; set; } + } + + public interface IX11InputMethodControl : IDisposable + { + void SetWindowActive(bool active); + bool IsEnabled { get; } + Task HandleEventAsync(RawKeyEventArgs args, int keyVal, int keyCode); + event Action OnCommit; + event Action OnForwardKey; + + void UpdateWindowInfo(PixelPoint position, double scaling); + } +} diff --git a/src/Avalonia.Input/InputElement.cs b/src/Avalonia.Input/InputElement.cs index 66fb9cfb1c..f3996cea76 100644 --- a/src/Avalonia.Input/InputElement.cs +++ b/src/Avalonia.Input/InputElement.cs @@ -5,6 +5,7 @@ using Avalonia.Controls; using Avalonia.Controls.Metadata; using Avalonia.Data; using Avalonia.Input.GestureRecognizers; +using Avalonia.Input.TextInput; using Avalonia.Interactivity; using Avalonia.VisualTree; @@ -103,6 +104,22 @@ namespace Avalonia.Input RoutedEvent.Register( "TextInput", RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent TextInputMethodClientRequestedEvent = + RoutedEvent.Register( + "TextInputMethodClientRequested", + RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent TextInputOptionsQueryEvent = + RoutedEvent.Register( + "TextInputOptionsQuery", + RoutingStrategies.Tunnel | RoutingStrategies.Bubble); /// /// Defines the event. @@ -243,6 +260,24 @@ namespace Avalonia.Input add { AddHandler(TextInputEvent, value); } remove { RemoveHandler(TextInputEvent, value); } } + + /// + /// Occurs when an input element gains input focus and input method is looking for the corresponding client + /// + public event EventHandler TextInputMethodClientRequested + { + add { AddHandler(TextInputMethodClientRequestedEvent, value); } + remove { RemoveHandler(TextInputMethodClientRequestedEvent, value); } + } + + /// + /// Occurs when an input element gains input focus and input method is asking for required content options + /// + public event EventHandler TextInputOptionsQuery + { + add { AddHandler(TextInputOptionsQueryEvent, value); } + remove { RemoveHandler(TextInputOptionsQueryEvent, value); } + } /// /// Occurs when the pointer enters the control. diff --git a/src/Avalonia.Input/KeyboardDevice.cs b/src/Avalonia.Input/KeyboardDevice.cs index 6f4cb7a35c..5899824c29 100644 --- a/src/Avalonia.Input/KeyboardDevice.cs +++ b/src/Avalonia.Input/KeyboardDevice.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; using Avalonia.Interactivity; using Avalonia.VisualTree; @@ -18,6 +19,10 @@ namespace Avalonia.Input public IInputManager InputManager => AvaloniaLocator.Current.GetService(); public IFocusManager FocusManager => AvaloniaLocator.Current.GetService(); + + // This should live in the FocusManager, but with the current outdated architecture + // the source of truth about the input focus is in KeyboardDevice + private readonly TextInputMethodManager _textInputManager = new TextInputMethodManager(); public IInputElement? FocusedElement { @@ -40,6 +45,7 @@ namespace Avalonia.Input } RaisePropertyChanged(); + _textInputManager.SetFocusedElement(value); } } diff --git a/src/Avalonia.Input/TextInput/ITextInputMethodClient.cs b/src/Avalonia.Input/TextInput/ITextInputMethodClient.cs new file mode 100644 index 0000000000..227496cc4a --- /dev/null +++ b/src/Avalonia.Input/TextInput/ITextInputMethodClient.cs @@ -0,0 +1,13 @@ +using System; +using Avalonia.VisualTree; + +namespace Avalonia.Input.TextInput +{ + public interface ITextInputMethodClient + { + Rect CursorRectangle { get; } + event EventHandler CursorRectangleChanged; + IVisual TextViewVisual { get; } + event EventHandler TextViewVisualChanged; + } +} diff --git a/src/Avalonia.Input/TextInput/ITextInputMethodImpl.cs b/src/Avalonia.Input/TextInput/ITextInputMethodImpl.cs new file mode 100644 index 0000000000..d33bee9e8e --- /dev/null +++ b/src/Avalonia.Input/TextInput/ITextInputMethodImpl.cs @@ -0,0 +1,14 @@ +namespace Avalonia.Input.TextInput +{ + public interface ITextInputMethodImpl + { + void SetActive(bool active); + void SetCursorRect(Rect rect); + void SetOptions(TextInputOptionsQueryEventArgs options); + } + + public interface ITextInputMethodRoot : IInputRoot + { + ITextInputMethodImpl InputMethod { get; } + } +} diff --git a/src/Avalonia.Input/TextInput/InputMethodManager.cs b/src/Avalonia.Input/TextInput/InputMethodManager.cs new file mode 100644 index 0000000000..592a12aec3 --- /dev/null +++ b/src/Avalonia.Input/TextInput/InputMethodManager.cs @@ -0,0 +1,100 @@ +using System; +using Avalonia.VisualTree; + +namespace Avalonia.Input.TextInput +{ + internal class TextInputMethodManager + { + private ITextInputMethodImpl? _im; + private IInputElement? _focusedElement; + private ITextInputMethodClient? _client; + private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper(); + + public TextInputMethodManager() => _transformTracker.MatrixChanged += UpdateCursorRect; + + private ITextInputMethodClient? Client + { + get => _client; + set + { + if(_client == value) + return; + if (_client != null) + { + _client.CursorRectangleChanged -= OnCursorRectangleChanged; + _client.TextViewVisualChanged -= OnTextViewVisualChanged; + } + + _client = value; + + if (_client != null) + { + _client.CursorRectangleChanged += OnCursorRectangleChanged; + _client.TextViewVisualChanged += OnTextViewVisualChanged; + var optionsQuery = new TextInputOptionsQueryEventArgs + { + RoutedEvent = InputElement.TextInputOptionsQueryEvent + }; + _focusedElement?.RaiseEvent(optionsQuery); + _im?.SetOptions(optionsQuery); + _transformTracker?.SetVisual(_client?.TextViewVisual); + UpdateCursorRect(); + _im?.SetActive(true); + } + else + { + _im?.SetActive(false); + _transformTracker.SetVisual(null); + } + } + } + + private void OnTextViewVisualChanged(object sender, EventArgs e) + => _transformTracker.SetVisual(_client?.TextViewVisual); + + private void UpdateCursorRect() + { + if (_im == null || _client == null || _focusedElement?.VisualRoot == null) + return; + var transform = _focusedElement.TransformToVisual(_focusedElement.VisualRoot); + if (transform == null) + _im.SetCursorRect(default); + else + _im.SetCursorRect(_client.CursorRectangle.TransformToAABB(transform.Value)); + } + + private void OnCursorRectangleChanged(object sender, EventArgs e) + { + if (sender == _client) + UpdateCursorRect(); + } + + public void SetFocusedElement(IInputElement? element) + { + if(_focusedElement == element) + return; + _focusedElement = element; + + var inputMethod = (element?.VisualRoot as ITextInputMethodRoot)?.InputMethod; + if(_im != inputMethod) + _im?.SetActive(false); + + _im = inputMethod; + + if (_focusedElement == null || _im == null) + { + Client = null; + _im?.SetActive(false); + return; + } + + var clientQuery = new TextInputMethodClientRequestedEventArgs + { + RoutedEvent = InputElement.TextInputMethodClientRequestedEvent + }; + + _focusedElement.RaiseEvent(clientQuery); + Client = clientQuery.Client; + } + } +} diff --git a/src/Avalonia.Input/TextInput/TextInputContentType.cs b/src/Avalonia.Input/TextInput/TextInputContentType.cs new file mode 100644 index 0000000000..5d73fc1552 --- /dev/null +++ b/src/Avalonia.Input/TextInput/TextInputContentType.cs @@ -0,0 +1,12 @@ +namespace Avalonia.Input.TextInput +{ + public enum TextInputContentType + { + Normal = 0, + Email = 1, + Phone = 2, + Number = 3, + Url = 4, + Password = 5 + } +} diff --git a/src/Avalonia.Input/TextInput/TextInputMethodClientRequestedEventArgs.cs b/src/Avalonia.Input/TextInput/TextInputMethodClientRequestedEventArgs.cs new file mode 100644 index 0000000000..bec43487d2 --- /dev/null +++ b/src/Avalonia.Input/TextInput/TextInputMethodClientRequestedEventArgs.cs @@ -0,0 +1,12 @@ +using Avalonia.Interactivity; + +namespace Avalonia.Input.TextInput +{ + public class TextInputMethodClientRequestedEventArgs : RoutedEventArgs + { + /// + /// Set this property to a valid text input client to enable input method interaction + /// + public ITextInputMethodClient? Client { get; set; } + } +} diff --git a/src/Avalonia.Input/TextInput/TextInputOptionsQueryEventArgs.cs b/src/Avalonia.Input/TextInput/TextInputOptionsQueryEventArgs.cs new file mode 100644 index 0000000000..924d0eb166 --- /dev/null +++ b/src/Avalonia.Input/TextInput/TextInputOptionsQueryEventArgs.cs @@ -0,0 +1,32 @@ +using Avalonia.Interactivity; + +namespace Avalonia.Input.TextInput +{ + public class TextInputOptionsQueryEventArgs : RoutedEventArgs + { + /// + /// The content type (mostly for determining the shape of the virtual keyboard) + /// + public TextInputContentType ContentType { get; set; } + /// + /// Text is multiline + /// + public bool Multiline { get; set; } + /// + /// Text is in lower case + /// + public bool Lowercase { get; set; } + /// + /// Text is in upper case + /// + public bool Uppercase { get; set; } + /// + /// Automatically capitalize letters at the start of the sentence + /// + public bool AutoCapitalization { get; set; } + /// + /// Text contains sensitive data like card numbers and should not be stored + /// + public bool IsSensitive { get; set; } + } +} diff --git a/src/Avalonia.Input/TextInput/TransformTrackingHelper.cs b/src/Avalonia.Input/TextInput/TransformTrackingHelper.cs new file mode 100644 index 0000000000..4211360a8f --- /dev/null +++ b/src/Avalonia.Input/TextInput/TransformTrackingHelper.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace Avalonia.Input.TextInput +{ + class TransformTrackingHelper : IDisposable + { + private IVisual? _visual; + private bool _queuedForUpdate; + private readonly EventHandler _propertyChangedHandler; + private readonly List _propertyChangedSubscriptions = new List(); + + public TransformTrackingHelper() + { + _propertyChangedHandler = PropertyChangedHandler; + } + + public void SetVisual(IVisual? visual) + { + Dispose(); + _visual = visual; + if (visual != null) + { + visual.AttachedToVisualTree += OnAttachedToVisualTree; + visual.DetachedFromVisualTree -= OnDetachedFromVisualTree; + if (visual.IsAttachedToVisualTree) + SubscribeToParents(); + UpdateMatrix(); + } + } + + public Matrix? Matrix { get; private set; } + public event Action? MatrixChanged; + + public void Dispose() + { + if(_visual == null) + return; + UnsubscribeFromParents(); + _visual.AttachedToVisualTree -= OnAttachedToVisualTree; + _visual.DetachedFromVisualTree -= OnDetachedFromVisualTree; + _visual = null; + } + + private void SubscribeToParents() + { + var visual = _visual; + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + // false positive + while (visual != null) + { + if (visual is Visual v) + { + v.PropertyChanged += _propertyChangedHandler; + _propertyChangedSubscriptions.Add(v); + } + + visual = visual.VisualParent; + } + } + + private void UnsubscribeFromParents() + { + foreach (var v in _propertyChangedSubscriptions) + v.PropertyChanged -= _propertyChangedHandler; + _propertyChangedSubscriptions.Clear(); + } + + void UpdateMatrix() + { + Matrix? matrix = null; + if (_visual != null && _visual.VisualRoot != null) + matrix = _visual.TransformToVisual(_visual.VisualRoot); + if (Matrix != matrix) + { + Matrix = matrix; + MatrixChanged?.Invoke(); + } + } + + private void OnAttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs visualTreeAttachmentEventArgs) + { + SubscribeToParents(); + UpdateMatrix(); + } + + private void EnqueueForUpdate() + { + if(_queuedForUpdate) + return; + _queuedForUpdate = true; + Dispatcher.UIThread.Post(UpdateMatrix, DispatcherPriority.Render); + } + + private void PropertyChangedHandler(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.IsEffectiveValueChange && e.Property == Visual.BoundsProperty) + EnqueueForUpdate(); + } + + private void OnDetachedFromVisualTree(object sender, VisualTreeAttachmentEventArgs visualTreeAttachmentEventArgs) + { + UnsubscribeFromParents(); + UpdateMatrix(); + } + } +} diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index c6db146f7b..e92fb259bd 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -4,6 +4,7 @@ using System.Reflection; using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.FreeDesktop; +using Avalonia.FreeDesktop.DBusIme; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.OpenGL; @@ -58,7 +59,7 @@ namespace Avalonia.X11 .Bind().ToConstant(new X11IconLoader(Info)) .Bind().ToConstant(new GtkSystemDialog()) .Bind().ToConstant(new LinuxMountedVolumeInfoProvider()); - + X11DBusImeHelper.RegisterIfNeeded(options.EnableIme); X11Screens = Avalonia.X11.X11Screens.Init(this); Screens = new X11Screens(X11Screens); if (Info.XInputVersion != null) @@ -103,6 +104,7 @@ namespace Avalonia public bool OverlayPopups { get; set; } public bool UseDBusMenu { get; set; } public bool UseDeferredRendering { get; set; } = true; + public bool? EnableIme { get; set; } public IList GlProfiles { get; set; } = new List { diff --git a/src/Avalonia.X11/X11Window.Ime.cs b/src/Avalonia.X11/X11Window.Ime.cs new file mode 100644 index 0000000000..6ad790dff5 --- /dev/null +++ b/src/Avalonia.X11/X11Window.Ime.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Avalonia.FreeDesktop; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; +using static Avalonia.X11.XLib; + +namespace Avalonia.X11 +{ + partial class X11Window + { + private ITextInputMethodImpl _ime; + private IX11InputMethodControl _imeControl; + private bool _processingIme; + + private Queue<(RawKeyEventArgs args, XEvent xev, int keyval, int keycode)> _imeQueue = + new Queue<(RawKeyEventArgs args, XEvent xev, int keyVal, int keyCode)>(); + + void InitializeIme() + { + var ime = AvaloniaLocator.Current.GetService()?.CreateClient(_handle); + if (ime != null) + { + (_ime, _imeControl) = ime.Value; + _imeControl.OnCommit += s => + ScheduleInput(new RawTextInputEventArgs(_keyboard, (ulong)_x11.LastActivityTimestamp.ToInt64(), + _inputRoot, s)); + _imeControl.OnForwardKey += ev => + { + ScheduleInput(new RawKeyEventArgs(_keyboard, (ulong)_x11.LastActivityTimestamp.ToInt64(), + _inputRoot, ev.Type, X11KeyTransform.ConvertKey((X11Key)ev.KeyVal), + (RawInputModifiers)ev.Modifiers)); + }; + } + } + + void UpdateImePosition() => _imeControl?.UpdateWindowInfo(Position, RenderScaling); + + async void HandleKeyEvent(XEvent ev) + { + + + var index = ev.KeyEvent.state.HasFlag(XModifierMask.ShiftMask); + + // We need the latin key, since it's mainly used for hotkeys, we use a different API for text anyway + var key = (X11Key)XKeycodeToKeysym(_x11.Display, ev.KeyEvent.keycode, index ? 1 : 0).ToInt32(); + + // Manually switch the Shift index for the keypad, + // there should be a proper way to do this + if (ev.KeyEvent.state.HasFlag(XModifierMask.Mod2Mask) + && key > X11Key.Num_Lock && key <= X11Key.KP_9) + key = (X11Key)XKeycodeToKeysym(_x11.Display, ev.KeyEvent.keycode, index ? 0 : 1).ToInt32(); + + var filtered = ScheduleKeyInput(new RawKeyEventArgs(_keyboard, (ulong)ev.KeyEvent.time.ToInt64(), _inputRoot, + ev.type == XEventName.KeyPress ? RawKeyEventType.KeyDown : RawKeyEventType.KeyUp, + X11KeyTransform.ConvertKey(key), TranslateModifiers(ev.KeyEvent.state)), ref ev, (int)key, ev.KeyEvent.keycode); + + if (_handle == IntPtr.Zero) + return; + + if (ev.type == XEventName.KeyPress && !filtered) + TriggerClassicTextInputEvent(ev); + } + + void TriggerClassicTextInputEvent(XEvent ev) + { + var text = TranslateEventToString(ev); + if (text != null) + ScheduleInput( + new RawTextInputEventArgs(_keyboard, (ulong)ev.KeyEvent.time.ToInt64(), _inputRoot, text), + ref ev); + } + + unsafe string TranslateEventToString(XEvent ev) + { + var buffer = stackalloc byte[40]; + var len = Xutf8LookupString(_xic, ref ev, buffer, 40, out _, out _); + if (len != 0) + { + var text = Encoding.UTF8.GetString(buffer, len); + if (text.Length == 1) + { + if (text[0] < ' ' || text[0] == 0x7f) //Control codes or DEL + return null; + } + + return text; + } + + return null; + } + + + bool ScheduleKeyInput(RawKeyEventArgs args, ref XEvent xev, int keyval, int keycode) + { + _x11.LastActivityTimestamp = xev.ButtonEvent.time; + if (_imeControl != null && _imeControl.IsEnabled) + { + if (FilterIme(args, xev, keyval, keycode)) + return true; + } + ScheduleInput(args); + return false; + } + + bool FilterIme(RawKeyEventArgs args, XEvent xev, int keyval, int keycode) + { + if (_ime == null) + return false; + _imeQueue.Enqueue((args, xev, keyval, keycode)); + if (!_processingIme) + ProcessNextImeEvent(); + + return true; + } + + async void ProcessNextImeEvent() + { + if(_processingIme) + return; + _processingIme = true; + try + { + while (_imeQueue.Count != 0) + { + var ev = _imeQueue.Dequeue(); + if (_imeControl == null || !await _imeControl.HandleEventAsync(ev.args, ev.keyval, ev.keycode)) + { + ScheduleInput(ev.args); + if (ev.args.Type == RawKeyEventType.KeyDown) + TriggerClassicTextInputEvent(ev.xev); + } + } + } + finally + { + _processingIme = false; + } + } + } +} diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 41c061613d..cb65dbe25f 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -5,12 +5,14 @@ using System.Diagnostics; using System.Linq; using System.Reactive.Disposables; using System.Text; +using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.FreeDesktop; using Avalonia.Input; using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; using Avalonia.OpenGL; using Avalonia.OpenGL.Egl; using Avalonia.Platform; @@ -22,9 +24,10 @@ using static Avalonia.X11.XLib; // ReSharper disable StringLiteralTypo namespace Avalonia.X11 { - unsafe class X11Window : IWindowImpl, IPopupImpl, IXI2Client, + unsafe partial class X11Window : IWindowImpl, IPopupImpl, IXI2Client, ITopLevelImplWithNativeMenuExporter, - ITopLevelImplWithNativeControlHost + ITopLevelImplWithNativeControlHost, + ITopLevelImplWithTextInputMethod { private readonly AvaloniaX11Platform _platform; private readonly IWindowImpl _popupParent; @@ -178,11 +181,13 @@ namespace Avalonia.X11 Surfaces = surfaces.ToArray(); UpdateMotifHints(); UpdateSizeHints(null); - _xic = XCreateIC(_x11.Xim, XNames.XNInputStyle, XIMProperties.XIMPreeditNothing | XIMProperties.XIMStatusNothing, - XNames.XNClientWindow, _handle, IntPtr.Zero); + _transparencyHelper = new TransparencyHelper(_x11, _handle, platform.Globals); _transparencyHelper.SetTransparencyRequest(WindowTransparencyLevel.None); - + _xic = XCreateIC(_x11.Xim, XNames.XNInputStyle, + XIMProperties.XIMPreeditNothing | XIMProperties.XIMStatusNothing, + XNames.XNClientWindow, _handle, IntPtr.Zero); + XFlush(_x11.Display); if(_popup) PopupPositioner = new ManagedPopupPositioner(new ManagedPopupPositionerPopupImplHelper(popupParent, MoveResize)); @@ -194,6 +199,7 @@ namespace Avalonia.X11 Paint?.Invoke(default); return _handle != IntPtr.Zero; }, TimeSpan.FromMilliseconds(100)); + InitializeIme(); } class SurfaceInfo : EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo @@ -386,9 +392,13 @@ namespace Avalonia.X11 if (ActivateTransientChildIfNeeded()) return; Activated?.Invoke(); + _imeControl.SetWindowActive(true); } else if (ev.type == XEventName.FocusOut) + { + _imeControl.SetWindowActive(false); Deactivated?.Invoke(); + } else if (ev.type == XEventName.MotionNotify) MouseEvent(RawPointerEventType.Move, ref ev, ev.MotionEvent.state); else if (ev.type == XEventName.LeaveNotify) @@ -477,6 +487,7 @@ namespace Avalonia.X11 PositionChanged?.Invoke(npos); updatedSizeViaScaling = UpdateScaling(); } + UpdateImePosition(); if (changedSize && !updatedSizeViaScaling && !_popup) Resized?.Invoke(ClientSize); @@ -507,39 +518,7 @@ namespace Avalonia.X11 { if (ActivateTransientChildIfNeeded()) return; - var buffer = stackalloc byte[40]; - - var index = ev.KeyEvent.state.HasFlag(XModifierMask.ShiftMask); - - // We need the latin key, since it's mainly used for hotkeys, we use a different API for text anyway - var key = (X11Key)XKeycodeToKeysym(_x11.Display, ev.KeyEvent.keycode, index ? 1 : 0).ToInt32(); - - // Manually switch the Shift index for the keypad, - // there should be a proper way to do this - if (ev.KeyEvent.state.HasFlag(XModifierMask.Mod2Mask) - && key > X11Key.Num_Lock && key <= X11Key.KP_9) - key = (X11Key)XKeycodeToKeysym(_x11.Display, ev.KeyEvent.keycode, index ? 0 : 1).ToInt32(); - - - ScheduleInput(new RawKeyEventArgs(_keyboard, (ulong)ev.KeyEvent.time.ToInt64(), _inputRoot, - ev.type == XEventName.KeyPress ? RawKeyEventType.KeyDown : RawKeyEventType.KeyUp, - X11KeyTransform.ConvertKey(key), TranslateModifiers(ev.KeyEvent.state)), ref ev); - - if (ev.type == XEventName.KeyPress) - { - var len = Xutf8LookupString(_xic, ref ev, buffer, 40, out _, out _); - if (len != 0) - { - var text = Encoding.UTF8.GetString(buffer, len); - if (text.Length == 1) - { - if (text[0] < ' ' || text[0] == 0x7f) //Control codes or DEL - return; - } - ScheduleInput(new RawTextInputEventArgs(_keyboard, (ulong)ev.KeyEvent.time.ToInt64(), _inputRoot, text), - ref ev); - } - } + HandleKeyEvent(ev); } } @@ -562,6 +541,7 @@ namespace Avalonia.X11 var oldScaledSize = ClientSize; RenderScaling = newScaling; ScalingChanged?.Invoke(RenderScaling); + UpdateImePosition(); SetMinMaxSize(_scaledMinMaxSize.minSize, _scaledMinMaxSize.maxSize); if(!skipResize) Resize(oldScaledSize, true); @@ -699,6 +679,7 @@ namespace Avalonia.X11 _x11.LastActivityTimestamp = xev.ButtonEvent.time; ScheduleInput(args); } + public void ScheduleXI2Input(RawInputEventArgs args) { @@ -781,6 +762,13 @@ namespace Avalonia.X11 void Cleanup() { + if (_imeControl != null) + { + _imeControl.Dispose(); + _imeControl = null; + _ime = null; + } + if (_xic != IntPtr.Zero) { XDestroyIC(_xic); @@ -1130,6 +1118,8 @@ namespace Avalonia.X11 public IPopupPositioner PopupPositioner { get; } public ITopLevelNativeMenuExporter NativeMenuExporter { get; } public INativeControlHostImpl NativeControlHost { get; } + public ITextInputMethodImpl TextInputMethod => _ime; + public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel) => _transparencyHelper.SetTransparencyRequest(transparencyLevel);