From 3e0179e1e033763a7bdbf13af98eaa06b836cf19 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Thu, 9 Feb 2023 20:27:29 +0000 Subject: [PATCH] add composing region to text input client, improves android composition --- .../Avalonia.Android/AndroidInputMethod.cs | 40 ++++-- src/Android/Avalonia.Android/InputEditable.cs | 93 ++++++++++++ .../Platform/SkiaPlatform/TopLevelImpl.cs | 132 +++--------------- .../Input/TextInput/ITextInputMethodClient.cs | 6 + .../Presenters/TextPresenter.cs | 33 ++++- .../TextBoxTextInputMethodClient.cs | 11 ++ 6 files changed, 192 insertions(+), 123 deletions(-) create mode 100644 src/Android/Avalonia.Android/InputEditable.cs diff --git a/src/Android/Avalonia.Android/AndroidInputMethod.cs b/src/Android/Avalonia.Android/AndroidInputMethod.cs index c885a7768c..459b20c410 100644 --- a/src/Android/Avalonia.Android/AndroidInputMethod.cs +++ b/src/Android/Avalonia.Android/AndroidInputMethod.cs @@ -5,8 +5,10 @@ using Android.Text; using Android.Views; using Android.Views.InputMethods; using Avalonia.Android.Platform.SkiaPlatform; +using Avalonia.Controls.Presenters; using Avalonia.Input; using Avalonia.Input.TextInput; +using Avalonia.Reactive; namespace Avalonia.Android { @@ -39,6 +41,7 @@ namespace Avalonia.Android private readonly InputMethodManager _imm; private ITextInputMethodClient _client; private AvaloniaInputConnection _inputConnection; + private IDisposable _textChangeObservable; public AndroidInputMethod(TView host) { @@ -70,13 +73,9 @@ namespace Avalonia.Android { if (_client != null) { + _textChangeObservable?.Dispose(); _client.SurroundingTextChanged -= SurroundingTextChanged; - } - - if(_inputConnection != null) - { - _inputConnection.ComposingText = null; - _inputConnection.ComposingRegion = default; + _client.TextViewVisualChanged -= TextViewVisualChanged; } _client = client; @@ -84,6 +83,12 @@ namespace Avalonia.Android if (IsActive) { _client.SurroundingTextChanged += SurroundingTextChanged; + _client.TextViewVisualChanged += TextViewVisualChanged; + + if(_client.TextViewVisual is TextPresenter textVisual) + { + _textChangeObservable = textVisual.GetObservable(TextPresenter.TextProperty).Subscribe(new AnonymousObserver(UpdateText)); + } _host.RequestFocus(); @@ -101,6 +106,23 @@ namespace Avalonia.Android } } + private void TextViewVisualChanged(object sender, EventArgs e) + { + var textVisual = _client.TextViewVisual as TextPresenter; + _textChangeObservable?.Dispose(); + _textChangeObservable = null; + + if(textVisual != null) + { + _textChangeObservable = textVisual.GetObservable(TextPresenter.TextProperty).Subscribe(new AnonymousObserver(UpdateText)); + } + } + + private void UpdateText(string? obj) + { + (_inputConnection?.Editable as InputEditable)?.UpdateString(obj); + } + private void SurroundingTextChanged(object sender, EventArgs e) { if (IsActive && _inputConnection != null) @@ -109,12 +131,10 @@ namespace Avalonia.Android _inputConnection.SurroundingText = surroundingText; - _imm.UpdateSelection(_host, surroundingText.AnchorOffset, surroundingText.CursorOffset, surroundingText.AnchorOffset, surroundingText.CursorOffset); - - if (_inputConnection.ComposingText != null && !_inputConnection.IsCommiting && surroundingText.AnchorOffset == surroundingText.CursorOffset) + if ((_inputConnection?.Editable as InputEditable)?.IsInBatchEdit != true) { - _inputConnection.CommitText(_inputConnection.ComposingText, 0); _inputConnection.SetSelection(surroundingText.AnchorOffset, surroundingText.CursorOffset); + _imm.UpdateSelection(_host, surroundingText.AnchorOffset, surroundingText.CursorOffset, surroundingText.AnchorOffset, surroundingText.CursorOffset); } } } diff --git a/src/Android/Avalonia.Android/InputEditable.cs b/src/Android/Avalonia.Android/InputEditable.cs new file mode 100644 index 0000000000..b702b0205c --- /dev/null +++ b/src/Android/Avalonia.Android/InputEditable.cs @@ -0,0 +1,93 @@ +using System; +using Android.Runtime; +using Android.Text; +using Android.Views; +using Android.Views.InputMethods; +using Avalonia.Android.Platform.SkiaPlatform; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Java.Lang; +using static System.Net.Mime.MediaTypeNames; + +namespace Avalonia.Android +{ + internal class InputEditable : SpannableStringBuilder + { + private readonly TopLevelImpl _topLevel; + private readonly IAndroidInputMethod _inputMethod; + private int _currentBatchLevel; + private string _previousText; + + public InputEditable(TopLevelImpl topLevel, IAndroidInputMethod inputMethod) + { + _topLevel = topLevel; + _inputMethod = inputMethod; + } + + public InputEditable(ICharSequence text) : base(text) + { + } + + public InputEditable(string text) : base(text) + { + } + + public InputEditable(ICharSequence text, int start, int end) : base(text, start, end) + { + } + + public InputEditable(string text, int start, int end) : base(text, start, end) + { + } + + protected InputEditable(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) + { + } + + public bool IsInBatchEdit => _currentBatchLevel > 0; + + public void BeginBatchEdit() + { + _currentBatchLevel++; + + if(_currentBatchLevel == 1) + { + _previousText = ToString(); + } + } + + public void EndBatchEdit() + { + if (_currentBatchLevel == 1) + { + _inputMethod.Client.SelectInSurroundingText(-1, _previousText.Length); + var time = DateTime.Now.TimeOfDay; + var currentText = ToString(); + + if (string.IsNullOrEmpty(currentText)) + { + _inputMethod.View.DispatchKeyEvent(new KeyEvent(KeyEventActions.Down, Keycode.ForwardDel)); + } + else + { + var rawTextEvent = new RawTextInputEventArgs(KeyboardDevice.Instance, (ulong)time.Ticks, _topLevel.InputRoot, currentText); + _topLevel.Input(rawTextEvent); + } + _inputMethod.Client.SelectInSurroundingText(Selection.GetSelectionStart(this), Selection.GetSelectionEnd(this)); + + _previousText = ""; + } + + _currentBatchLevel--; + } + + public void UpdateString(string? text) + { + if(text != ToString()) + { + Clear(); + Insert(0, text); + } + } + } +} diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 693a26f3bd..b6bcc4ac9f 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -410,27 +410,23 @@ namespace Avalonia.Android.Platform.SkiaPlatform { private readonly TopLevelImpl _topLevel; private readonly IAndroidInputMethod _inputMethod; + private readonly InputEditable _editable; public AvaloniaInputConnection(TopLevelImpl topLevel, IAndroidInputMethod inputMethod) : base(inputMethod.View, true) { _topLevel = topLevel; _inputMethod = inputMethod; + _editable = new InputEditable(_topLevel, _inputMethod); } public TextInputMethodSurroundingText SurroundingText { get; set; } - public string ComposingText { get; internal set; } - - public ComposingRegion? ComposingRegion { get; internal set; } - - public bool IsComposing => !string.IsNullOrEmpty(ComposingText); - public bool IsCommiting { get; private set; } + public override IEditable Editable => _editable; public override bool SetComposingRegion(int start, int end) { - //System.Diagnostics.Debug.WriteLine($"Composing Region: [{start}|{end}] {SurroundingText.Text?.Substring(start, end - start)}"); - - ComposingRegion = new ComposingRegion(start, end); + _inputMethod.Client.SetPreeditText(null); + _inputMethod.Client.SetComposingRegion(new Media.TextFormatting.TextRange(start, end)); return base.SetComposingRegion(start, end); } @@ -439,132 +435,46 @@ namespace Avalonia.Android.Platform.SkiaPlatform { var composingText = text.ToString(); - ComposingText = composingText; - - _inputMethod.Client?.SetPreeditText(ComposingText); - - return base.SetComposingText(text, newCursorPosition); - } - - public override bool FinishComposingText() - { - if (!string.IsNullOrEmpty(ComposingText)) + if (string.IsNullOrEmpty(composingText)) { - CommitText(ComposingText, ComposingText.Length); + return CommitText(text, newCursorPosition); } else { - ComposingRegion = new ComposingRegion(SurroundingText.CursorOffset, SurroundingText.CursorOffset); + return base.SetComposingText(text, newCursorPosition); } - - return base.FinishComposingText(); } - public override ICharSequence GetTextBeforeCursorFormatted(int length, [GeneratedEnum] GetTextFlags flags) + public override bool BeginBatchEdit() { - if (!string.IsNullOrEmpty(SurroundingText.Text) && length > 0) - { - var start = System.Math.Max(SurroundingText.CursorOffset - length, 0); - - var end = System.Math.Min(start + length - 1, SurroundingText.CursorOffset); - - var text = SurroundingText.Text.Substring(start, end - start); + _editable.BeginBatchEdit(); - //System.Diagnostics.Debug.WriteLine($"Text Before: {text}"); - - return new Java.Lang.String(text); - } - - return null; + return base.BeginBatchEdit(); } - public override ICharSequence GetTextAfterCursorFormatted(int length, [GeneratedEnum] GetTextFlags flags) + public override bool EndBatchEdit() { - if (!string.IsNullOrEmpty(SurroundingText.Text)) - { - var start = SurroundingText.CursorOffset; - - var end = System.Math.Min(start + length, SurroundingText.Text.Length); - - var text = SurroundingText.Text.Substring(start, end - start); - - //System.Diagnostics.Debug.WriteLine($"Text After: {text}"); + var ret = base.EndBatchEdit(); + _editable.EndBatchEdit(); - return new Java.Lang.String(text); - } + return ret; + } - return null; + public override bool FinishComposingText() + { + _inputMethod.Client?.SetComposingRegion(null); + return base.FinishComposingText(); } public override bool CommitText(ICharSequence text, int newCursorPosition) { - IsCommiting = true; - var committedText = text.ToString(); - _inputMethod.Client.SetPreeditText(null); - int? start, end; - - if(SurroundingText.CursorOffset != SurroundingText.AnchorOffset) - { - start = Math.Min(SurroundingText.CursorOffset, SurroundingText.AnchorOffset); - end = Math.Max(SurroundingText.CursorOffset, SurroundingText.AnchorOffset); - } - else if (ComposingRegion != null) - { - start = ComposingRegion?.Start; - end = ComposingRegion?.End; - - ComposingRegion = null; - } - else - { - start = end = _inputMethod.Client.SurroundingText.CursorOffset; - } - - _inputMethod.Client.SelectInSurroundingText((int)start, (int)end); - - var time = DateTime.Now.TimeOfDay; - - var rawTextEvent = new RawTextInputEventArgs(KeyboardDevice.Instance, (ulong)time.Ticks, _topLevel.InputRoot, committedText); - - _topLevel.Input(rawTextEvent); - - ComposingText = null; - - ComposingRegion = new ComposingRegion(newCursorPosition, newCursorPosition); + _inputMethod.Client?.SetComposingRegion(null); return base.CommitText(text, newCursorPosition); } - public override bool DeleteSurroundingText(int beforeLength, int afterLength) - { - var surroundingText = _inputMethod.Client.SurroundingText; - - var selectionStart = surroundingText.CursorOffset; - - _inputMethod.Client.SelectInSurroundingText(selectionStart - beforeLength, selectionStart + afterLength); - - _inputMethod.View.DispatchKeyEvent(new KeyEvent(KeyEventActions.Down, Keycode.ForwardDel)); - - surroundingText = _inputMethod.Client.SurroundingText; - - selectionStart = surroundingText.CursorOffset; - - ComposingRegion = new ComposingRegion(selectionStart, selectionStart); - - return base.DeleteSurroundingText(beforeLength, afterLength); - } - - public override bool SetSelection(int start, int end) - { - _inputMethod.Client.SelectInSurroundingText(start, end); - - ComposingRegion = new ComposingRegion(start, end); - - return base.SetSelection(start, end); - } - public override bool PerformEditorAction([GeneratedEnum] ImeAction actionCode) { switch (actionCode) diff --git a/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs b/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs index 531cf3c704..8239bc6a21 100644 --- a/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs +++ b/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Media.TextFormatting; using Avalonia.VisualTree; namespace Avalonia.Input.TextInput @@ -30,6 +31,11 @@ namespace Avalonia.Input.TextInput /// void SetPreeditText(string? text); + /// + /// Sets the current composing region. This doesn't remove the composing text from the commited text. + /// + void SetComposingRegion(TextRange? region); + /// /// Indicates if text input client is capable of providing the text around the cursor /// diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 3481b1ecf3..bb6b03d59a 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -63,6 +63,15 @@ namespace Avalonia.Controls.Presenters o => o.PreeditText, (o, v) => o.PreeditText = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty CompositionRegionProperty = + AvaloniaProperty.RegisterDirect( + nameof(CompositionRegion), + o => o.CompositionRegion, + (o, v) => o.CompositionRegion = v); + /// /// Defines the property. /// @@ -106,6 +115,7 @@ namespace Avalonia.Controls.Presenters private Rect _caretBounds; private Point _navigationPosition; private string? _preeditText; + private TextRange? _compositionRegion; static TextPresenter() { @@ -146,6 +156,12 @@ namespace Avalonia.Controls.Presenters set => SetAndRaise(PreeditTextProperty, ref _preeditText, value); } + public TextRange? CompositionRegion + { + get => _compositionRegion; + set => SetAndRaise(CompositionRegionProperty, ref _compositionRegion, value); + } + /// /// Gets or sets the font family. /// @@ -548,7 +564,20 @@ namespace Avalonia.Controls.Presenters var foreground = Foreground; - if (!string.IsNullOrEmpty(_preeditText)) + if(_compositionRegion != null) + { + var preeditHighlight = new ValueSpan(_compositionRegion?.Start ?? 0, _compositionRegion?.Length ?? 0, + new GenericTextRunProperties(typeface, FontSize, + foregroundBrush: foreground, + textDecorations: TextDecorations.Underline)); + + textStyleOverrides = new[] + { + preeditHighlight + }; + + } + else if (!string.IsNullOrEmpty(_preeditText)) { var preeditHighlight = new ValueSpan(_caretIndex, _preeditText.Length, new GenericTextRunProperties(typeface, FontSize, @@ -911,6 +940,7 @@ namespace Avalonia.Controls.Presenters break; } + case nameof(CompositionRegion): case nameof(Foreground): case nameof(FontSize): case nameof(FontStyle): @@ -931,7 +961,6 @@ namespace Avalonia.Controls.Presenters case nameof(PasswordChar): case nameof(RevealPassword): - case nameof(FlowDirection): { InvalidateTextLayout(); diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 10c2f36f43..239501aace 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -5,6 +5,7 @@ using Avalonia.Media.TextFormatting; using Avalonia.Threading; using Avalonia.Utilities; using Avalonia.VisualTree; +using static System.Net.Mime.MediaTypeNames; namespace Avalonia.Controls { @@ -110,6 +111,16 @@ namespace Avalonia.Controls _presenter.PreeditText = text; } + public void SetComposingRegion(TextRange? region) + { + if (_presenter == null) + { + return; + } + + _presenter.CompositionRegion = region; + } + public void SelectInSurroundingText(int start, int end) { if(_parent is null ||_presenter is null)