Browse Source

X11 IME preedit, preedit cursor, input context improvements

pull/13282/head
Nikita Tsukanov 2 years ago
parent
commit
2adf3d5626
  1. 26
      src/Avalonia.Base/Input/InputMethod.cs
  2. 63
      src/Avalonia.Base/Input/TextInput/InputMethodManager.cs
  3. 18
      src/Avalonia.Base/Input/TextInput/TextInputMethodClient.cs
  4. 8
      src/Avalonia.Base/Input/TextInput/TextInputMethodClientRequeryRequestedEventArgs.cs
  5. 25
      src/Avalonia.Base/Media/TextFormatting/Unicode/Utf16Utils.cs
  6. 28
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  7. 5
      src/Avalonia.Controls/TextBoxTextInputMethodClient.cs
  8. 16
      src/Avalonia.FreeDesktop/DBusIme/DBusTextInputMethodBase.cs
  9. 6
      src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxICWrapper.cs
  10. 104
      src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs
  11. 54
      src/Avalonia.FreeDesktop/DBusIme/IBus/IBusX11TextInputMethod.cs
  12. 2
      src/Avalonia.X11/X11Platform.cs
  13. 34
      tests/Avalonia.Base.UnitTests/Media/TextFormatting/Utf16UtilsTests.cs

26
src/Avalonia.Base/Input/InputMethod.cs

@ -1,4 +1,8 @@
namespace Avalonia.Input
using System;
using Avalonia.Input.TextInput;
using Avalonia.Interactivity;
namespace Avalonia.Input
{
public class InputMethod
{
@ -23,7 +27,25 @@
{
return target.GetValue<bool>(IsInputMethodEnabledProperty);
}
/// <summary>
/// Defines the <see cref="TextInputMethodClientRequeryRequested"/> event.
/// </summary>
public static readonly RoutedEvent<TextInputMethodClientRequeryRequestedEventArgs> TextInputMethodClientRequeryRequestedEvent =
RoutedEvent.Register<InputElement, TextInputMethodClientRequeryRequestedEventArgs>(
"TextInputMethodClientRequeryRequested",
RoutingStrategies.Bubble);
public static void AddTextInputMethodClientRequeryRequestedHandler(Interactive element, EventHandler<RoutedEventArgs> handler)
{
element.AddHandler(TextInputMethodClientRequeryRequestedEvent, handler);
}
public static void RemoveTextInputMethodClientRequeryRequestedHandler(Interactive element, EventHandler<RoutedEventArgs> handler)
{
element.AddHandler(TextInputMethodClientRequeryRequestedEvent, handler);
}
private InputMethod()
{

63
src/Avalonia.Base/Input/TextInput/InputMethodManager.cs

@ -1,4 +1,5 @@
using System;
using Avalonia.Interactivity;
using Avalonia.Reactive;
namespace Avalonia.Input.TextInput
@ -7,6 +8,7 @@ namespace Avalonia.Input.TextInput
{
private ITextInputMethodImpl? _im;
private IInputElement? _focusedElement;
private Interactive? _visualRoot;
private TextInputMethodClient? _client;
private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper();
@ -30,6 +32,7 @@ namespace Avalonia.Input.TextInput
{
_client.CursorRectangleChanged -= OnCursorRectangleChanged;
_client.TextViewVisualChanged -= OnTextViewVisualChanged;
_client.ResetRequested -= OnResetRequested;
_client = null;
@ -42,21 +45,9 @@ namespace Avalonia.Input.TextInput
{
_client.CursorRectangleChanged += OnCursorRectangleChanged;
_client.TextViewVisualChanged += OnTextViewVisualChanged;
_client.ResetRequested += OnResetRequested;
if (_focusedElement is StyledElement target)
{
_im?.SetOptions(TextInputOptions.FromStyledElement(target));
}
else
{
_im?.SetOptions(TextInputOptions.Default);
}
_transformTracker.SetVisual(_client?.TextViewVisual);
_im?.SetClient(_client);
UpdateCursorRect();
PopulateImWithInitialValues();
}
else
{
@ -66,6 +57,33 @@ namespace Avalonia.Input.TextInput
}
}
void PopulateImWithInitialValues()
{
if (_focusedElement is StyledElement target)
{
_im?.SetOptions(TextInputOptions.FromStyledElement(target));
}
else
{
_im?.SetOptions(TextInputOptions.Default);
}
_transformTracker.SetVisual(_client?.TextViewVisual);
_im?.SetClient(_client);
UpdateCursorRect();
}
private void OnResetRequested(object? sender, EventArgs args)
{
if (_im != null && sender == _client)
{
_im.Reset();
PopulateImWithInitialValues();
}
}
private void OnIsInputMethodEnabledChanged(AvaloniaPropertyChangedEventArgs<bool> obj)
{
if (ReferenceEquals(obj.Sender, _focusedElement))
@ -102,8 +120,18 @@ namespace Avalonia.Input.TextInput
{
if(_focusedElement == element)
return;
if (_visualRoot != null)
InputMethod.RemoveTextInputMethodClientRequeryRequestedHandler(_visualRoot,
TextInputMethodClientRequeryRequested);
_focusedElement = element;
_visualRoot = (element as Visual)?.VisualRoot as Interactive;
if (_visualRoot != null)
InputMethod.AddTextInputMethodClientRequeryRequestedHandler(_visualRoot,
TextInputMethodClientRequeryRequested);
var inputMethod = ((element as Visual)?.VisualRoot as ITextInputMethodRoot)?.InputMethod;
if (_im != inputMethod)
@ -112,10 +140,17 @@ namespace Avalonia.Input.TextInput
}
_im = inputMethod;
TryFindAndApplyClient();
}
private void TextInputMethodClientRequeryRequested(object? sender, RoutedEventArgs e)
{
if (_im != null)
TryFindAndApplyClient();
}
private void TryFindAndApplyClient()
{
if (_focusedElement is not InputElement focused ||

18
src/Avalonia.Base/Input/TextInput/TextInputMethodClient.cs

@ -23,6 +23,11 @@ namespace Avalonia.Input.TextInput
/// Fires when the selection has changed
/// </summary>
public event EventHandler? SelectionChanged;
/// <summary>
/// Fires when client wants to reset IME state
/// </summary>
public event EventHandler? ResetRequested;
/// <summary>
/// The visual that's showing the text
@ -59,6 +64,14 @@ namespace Avalonia.Input.TextInput
/// </summary>
public virtual void SetPreeditText(string? preeditText) { }
/// <summary>
/// Sets the non-committed input string and cursor offset in that string
/// </summary>
public virtual void SetPreeditText(string? preeditText, int? cursorPos)
{
SetPreeditText(preeditText);
}
protected virtual void RaiseTextViewVisualChanged()
{
TextViewVisualChanged?.Invoke(this, EventArgs.Empty);
@ -78,6 +91,11 @@ namespace Avalonia.Input.TextInput
{
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
protected virtual void RequestReset()
{
ResetRequested?.Invoke(this, EventArgs.Empty);
}
}
public record struct TextSelection(int Start, int End);

8
src/Avalonia.Base/Input/TextInput/TextInputMethodClientRequeryRequestedEventArgs.cs

@ -0,0 +1,8 @@
using Avalonia.Interactivity;
namespace Avalonia.Input.TextInput;
public class TextInputMethodClientRequeryRequestedEventArgs : RoutedEventArgs
{
}

25
src/Avalonia.Base/Media/TextFormatting/Unicode/Utf16Utils.cs

@ -0,0 +1,25 @@
using System;
namespace Avalonia.Media.TextFormatting.Unicode;
internal class Utf16Utils
{
public static int CharacterOffsetToStringOffset(string s, int off, bool throwOnOutOfRange)
{
if (off == 0)
return 0;
var symbolOffset = 0;
for (var c = 0; c < s.Length; c++)
{
if (symbolOffset == off)
return c;
if (!char.IsSurrogatePair(s, c))
symbolOffset++;
}
if (throwOnOutOfRange)
throw new IndexOutOfRangeException();
return s.Length;
}
}

28
src/Avalonia.Controls/Presenters/TextPresenter.cs

@ -49,6 +49,12 @@ namespace Avalonia.Controls.Presenters
/// </summary>
public static readonly StyledProperty<string?> PreeditTextProperty =
AvaloniaProperty.Register<TextPresenter, string?>(nameof(PreeditText));
/// <summary>
/// Defines the <see cref="PreeditText"/> property.
/// </summary>
public static readonly StyledProperty<int?> PreeditTextCursorPositionProperty =
AvaloniaProperty.Register<TextPresenter, int?>(nameof(PreeditTextCursorPosition));
/// <summary>
/// Defines the <see cref="TextAlignment"/> property.
@ -125,6 +131,12 @@ namespace Avalonia.Controls.Presenters
get => GetValue(PreeditTextProperty);
set => SetValue(PreeditTextProperty, value);
}
public int? PreeditTextCursorPosition
{
get => GetValue(PreeditTextCursorPositionProperty);
set => SetValue(PreeditTextCursorPositionProperty, value);
}
/// <summary>
/// Gets or sets the font family.
@ -828,8 +840,8 @@ namespace Avalonia.Controls.Presenters
_caretTimer.Tick -= CaretTimerTick;
}
private void OnPreeditTextChanged(string? preeditText)
private void OnPreeditChanged(string? preeditText, int? cursorPosition)
{
if (string.IsNullOrEmpty(preeditText))
{
@ -837,7 +849,10 @@ namespace Avalonia.Controls.Presenters
}
else
{
UpdateCaret(new CharacterHit(CaretIndex + preeditText.Length), false);
var cursorPos = cursorPosition is >= 0 && cursorPosition <= preeditText.Length
? cursorPosition.Value
: preeditText.Length;
UpdateCaret(new CharacterHit(CaretIndex + cursorPos), false);
InvalidateMeasure();
CaretChanged();
}
@ -854,7 +869,12 @@ namespace Avalonia.Controls.Presenters
if(change.Property == PreeditTextProperty)
{
OnPreeditTextChanged(change.NewValue as string);
OnPreeditChanged(change.NewValue as string, PreeditTextCursorPosition);
}
if(change.Property == PreeditTextCursorPositionProperty)
{
OnPreeditChanged(PreeditText, PreeditTextCursorPosition);
}
if(change.Property == TextProperty)

5
src/Avalonia.Controls/TextBoxTextInputMethodClient.cs

@ -145,7 +145,9 @@ namespace Avalonia.Controls
RaiseCursorRectangleChanged();
}
public override void SetPreeditText(string? preeditText)
public override void SetPreeditText(string? preeditText) => SetPreeditText(preeditText, null);
public override void SetPreeditText(string? preeditText, int? cursorPos)
{
if (_presenter == null || _parent == null)
{
@ -153,6 +155,7 @@ namespace Avalonia.Controls
}
_presenter.SetCurrentValue(TextPresenter.PreeditTextProperty, preeditText);
_presenter.SetCurrentValue(TextPresenter.PreeditTextCursorPositionProperty, cursorPos);
}
private static string GetTextLineText(TextLine textLine)

16
src/Avalonia.FreeDesktop/DBusIme/DBusTextInputMethodBase.cs

@ -53,7 +53,7 @@ namespace Avalonia.FreeDesktop.DBusIme
_ = WatchAsync();
}
public TextInputMethodClient Client => _client;
public TextInputMethodClient? Client => _client;
public bool IsActive => _client is not null;
@ -190,6 +190,8 @@ namespace Avalonia.FreeDesktop.DBusIme
protected abstract Task SetCursorRectCore(PixelRect rect);
protected abstract Task SetActiveCore(bool active);
protected virtual Task SetCapabilitiesCore(bool supportsPreedit, bool supportsSurroundingText) => Task.CompletedTask;
protected abstract Task ResetContextCore();
protected abstract Task<bool> HandleKeyCore(RawKeyEventArgs args, int keyVal, int keyCode);
@ -208,6 +210,17 @@ namespace Avalonia.FreeDesktop.DBusIme
}
});
}
private void UpdateCapabilities(bool supportsPreedit, bool supportsSurroundingText)
{
_queue.Enqueue(async () =>
{
if(!IsConnected)
return;
await SetCapabilitiesCore(supportsPreedit, supportsSurroundingText);
});
}
void IX11InputMethodControl.SetWindowActive(bool active)
@ -220,6 +233,7 @@ namespace Avalonia.FreeDesktop.DBusIme
{
_client = client;
UpdateActive();
UpdateCapabilities(client?.SupportsPreedit ?? false, client?.SupportsSurroundingText ?? false);
}
bool IX11InputMethodControl.IsEnabled => IsConnected && _imeActive == true;

6
src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxICWrapper.cs

@ -47,6 +47,12 @@ namespace Avalonia.FreeDesktop.DBusIme.Fcitx
?? _modern?.WatchForwardKeyAsync((e, ev) => handler.Invoke(e, (ev.keyval, ev.state, ev.type ? 1 : 0)))
?? new ValueTask<IDisposable?>(default(IDisposable?));
public ValueTask<IDisposable?> WatchUpdateFormattedPreeditAsync(
Action<Exception?, ((string, int)[] @str, int @cursorpos)> handler) =>
_old?.WatchUpdateFormattedPreeditAsync(handler)
?? _modern?.WatchUpdateFormattedPreeditAsync(handler)
?? new(default);
public Task SetCapacityAsync(uint flags) =>
_old?.SetCapacityAsync(flags) ?? _modern?.SetCapabilityAsync(flags) ?? Task.CompletedTask;
}

104
src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs

@ -1,5 +1,7 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Raw;
@ -14,6 +16,8 @@ namespace Avalonia.FreeDesktop.DBusIme.Fcitx
{
private FcitxICWrapper? _context;
private FcitxCapabilityFlags? _lastReportedFlags;
private FcitxCapabilityFlags _optionFlags;
private FcitxCapabilityFlags _capabilityFlags;
public FcitxX11TextInputMethod(Connection connection) : base(connection, "org.fcitx.Fcitx", "org.freedesktop.portal.Fcitx") { }
@ -38,9 +42,36 @@ namespace Avalonia.FreeDesktop.DBusIme.Fcitx
AddDisposable(await _context.WatchCommitStringAsync(OnCommitString));
AddDisposable(await _context.WatchForwardKeyAsync(OnForward));
AddDisposable(await _context.WatchUpdateFormattedPreeditAsync(OnPreedit));
return true;
}
private void OnPreedit(Exception? arg1, ((string, int)[] str, int cursorpos) args)
{
int? cursor = null;
string preeditString = null;
if (args.str != null! && args.str.Length > 0)
{
preeditString = string.Join("", args.str.Select(x => x.Item1));
if (preeditString.Length > 0 && args.cursorpos >= 0)
{
// cursorpos is a byte offset in UTF8 sequence that got sent through dbus
// Tmds.DBus has already converted it to UTF16, so we need to convert it back
// and figure out the byte offset
var utf8String = Encoding.UTF8.GetBytes(preeditString);
if (utf8String.Length >= args.cursorpos)
{
cursor = Encoding.UTF8.GetCharCount(utf8String, 0, args.cursorpos);
}
}
}
if (Client?.SupportsPreedit == true)
Client.SetPreeditText(preeditString, cursor);
}
protected override Task DisconnectAsync() => _context?.DestroyICAsync() ?? Task.CompletedTask;
protected override void OnDisconnected() => _context = null;
@ -85,33 +116,56 @@ namespace Avalonia.FreeDesktop.DBusIme.Fcitx
return false;
}
private void UpdateOptionsField(TextInputOptions options)
{
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.Digits)
flags |= FcitxCapabilityFlags.CAPACITY_DIALABLE;
else if (options.ContentType == TextInputContentType.Url)
flags |= FcitxCapabilityFlags.CAPACITY_URL;
_optionFlags = flags;
}
async Task PushFlagsIfNeeded()
{
if(_context == null)
return;
var flags = _optionFlags | _capabilityFlags;
if (flags != _lastReportedFlags)
{
_lastReportedFlags = flags;
await _context.SetCapacityAsync((uint)flags);
}
}
protected override Task SetCapabilitiesCore(bool supportsPreedit, bool supportsSurroundingText)
{
_capabilityFlags = default;
if (supportsPreedit)
_capabilityFlags = FcitxCapabilityFlags.CAPACITY_PREEDIT;
return PushFlagsIfNeeded();
}
public override void SetOptions(TextInputOptions options) =>
Enqueue(async () =>
Enqueue(() =>
{
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.Digits)
flags |= FcitxCapabilityFlags.CAPACITY_DIALABLE;
else if (options.ContentType == TextInputContentType.Url)
flags |= FcitxCapabilityFlags.CAPACITY_URL;
if (flags != _lastReportedFlags)
{
_lastReportedFlags = flags;
await _context.SetCapacityAsync((uint)flags);
}
UpdateOptionsField(options);
return PushFlagsIfNeeded();
});
private void OnForward(Exception? e, (uint keyval, uint state, int type) ev)

54
src/Avalonia.FreeDesktop/DBusIme/IBus/IBusX11TextInputMethod.cs

@ -4,6 +4,7 @@ using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Input.TextInput;
using Avalonia.Logging;
using Avalonia.Media.TextFormatting.Unicode;
using Tmds.DBus.Protocol;
using Tmds.DBus.SourceGenerator;
@ -14,6 +15,9 @@ namespace Avalonia.FreeDesktop.DBusIme.IBus
{
private OrgFreedesktopIBusService? _service;
private OrgFreedesktopIBusInputContext? _context;
private string? _preeditText;
private int _preeditCursor;
private bool _preeditShown = true;
public IBusX11TextInputMethod(Connection connection) : base(connection, "org.freedesktop.portal.IBus") { }
@ -25,10 +29,47 @@ namespace Avalonia.FreeDesktop.DBusIme.IBus
_context = new OrgFreedesktopIBusInputContext(Connection, name, path);
AddDisposable(await _context.WatchCommitTextAsync(OnCommitText));
AddDisposable(await _context.WatchForwardKeyEventAsync(OnForwardKey));
AddDisposable(await _context.WatchUpdatePreeditTextAsync(OnUpdatePreedit));
AddDisposable(await _context.WatchShowPreeditTextAsync(OnShowPreedit));
AddDisposable(await _context.WatchHidePreeditTextAsync(OnHidePreedit));
Enqueue(() => _context.SetCapabilitiesAsync((uint)IBusCapability.CapFocus));
return true;
}
private void OnHidePreedit(Exception? obj)
{
_preeditShown = false;
if (Client?.SupportsPreedit == true)
Client.SetPreeditText(null, null);
}
private void OnShowPreedit(Exception? obj)
{
_preeditShown = true;
if (Client?.SupportsPreedit == true)
Client.SetPreeditText(_preeditText, _preeditText == null ? null : _preeditCursor);
}
private void OnUpdatePreedit(Exception? arg1, (DBusVariantItem text, uint cursor_pos, bool visible) preeditComponents)
{
if (preeditComponents.text.Value is DBusStructItem { Count: >= 3 } structItem &&
structItem[2] is DBusStringItem stringItem)
{
_preeditText = stringItem.Value;
_preeditCursor = _preeditText != null
? Utf16Utils.CharacterOffsetToStringOffset(_preeditText,
(int)Math.Min(preeditComponents.cursor_pos, int.MaxValue), false)
: 0;
_preeditShown = true;
if (Client?.SupportsPreedit == true)
Client.SetPreeditText(
_preeditText, _preeditCursor);
}
}
private void OnForwardKey(Exception? e, (uint keyval, uint keycode, uint state) k)
{
if (e is not null)
@ -85,7 +126,10 @@ namespace Avalonia.FreeDesktop.DBusIme.IBus
?? Task.CompletedTask;
protected override Task ResetContextCore()
=> _context?.ResetAsync() ?? Task.CompletedTask;
{
_preeditShown = true;
return _context?.ResetAsync() ?? Task.CompletedTask;
}
protected override Task<bool> HandleKeyCore(RawKeyEventArgs args, int keyVal, int keyCode)
{
@ -109,5 +153,13 @@ namespace Avalonia.FreeDesktop.DBusIme.IBus
{
// No-op, because ibus
}
protected override async Task SetCapabilitiesCore(bool supportsPreedit, bool supportsSurroundingText)
{
var caps = IBusCapability.CapFocus;
if (supportsPreedit)
caps |= IBusCapability.CapPreeditText;
await _context.SetCapabilitiesAsync((uint)caps);
}
}
}

2
src/Avalonia.X11/X11Platform.cs

@ -283,7 +283,7 @@ namespace Avalonia
/// Input method editor is a component that enables users to generate characters not natively available
/// on their input devices by using sequences of characters or mouse operations that are natively available on their input devices.
/// </remarks>
public bool? EnableIme { get; set; }
public bool? EnableIme { get; set; } = true;
/// <summary>
/// Determines whether to enable support for the

34
tests/Avalonia.Base.UnitTests/Media/TextFormatting/Utf16UtilsTests.cs

@ -0,0 +1,34 @@
using System;
using Avalonia.Media.TextFormatting.Unicode;
using Xunit;
namespace Avalonia.Base.UnitTests.Media.TextFormatting;
public class Utf16UtilsTests
{
[Theory,
InlineData("\ud87e\udc32123", 1, 2),
InlineData("\ud87e\udc32123", 2, 3),
InlineData("test", 3, 3),
InlineData("\ud87e\udc32", 0, 0),
InlineData("12\ud87e\udc3212", 2, 2),
InlineData("12\ud87e\udc3212", 3, 4),
]
public void CharacterOffsetToStringOffset(string s, int charOffset, int stringOffset)
{
Assert.Equal(stringOffset, Utf16Utils.CharacterOffsetToStringOffset(s, charOffset, false));
}
[Theory,
InlineData("\ud87e\udc32", 2, true),
InlineData("12", 2, true),
]
public void CharacterOffsetToStringOffsetThrowsOnOutOfRange(string s, int charOffset, bool throws)
{
if (throws)
Assert.Throws<IndexOutOfRangeException>(() =>
Utf16Utils.CharacterOffsetToStringOffset(s, charOffset, true));
else
Utf16Utils.CharacterOffsetToStringOffset(s, charOffset, true);
}
}
Loading…
Cancel
Save