Browse Source

add composing region to text input client, improves android composition

Emmanuel Hansen 2 years ago
parent
commit
3e0179e1e0

+ 30 - 10
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<string?>(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<string?>(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);
                 }
             }
         }

+ 93 - 0
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);
+            }
+        }
+    }
+}

+ 21 - 111
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)

+ 6 - 0
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
         /// </summary>
         void SetPreeditText(string? text);
 
+        /// <summary>
+        /// Sets the current composing region. This doesn't remove the composing text from the commited text.
+        /// </summary>
+        void SetComposingRegion(TextRange? region);
+
         /// <summary>
         /// Indicates if text input client is capable of providing the text around the cursor
         /// </summary>

+ 31 - 2
src/Avalonia.Controls/Presenters/TextPresenter.cs

@@ -63,6 +63,15 @@ namespace Avalonia.Controls.Presenters
                 o => o.PreeditText,
                  (o, v) => o.PreeditText = v);
 
+        /// <summary>
+        /// Defines the <see cref="CompositionRegion"/> property.
+        /// </summary>
+        public static readonly DirectProperty<TextPresenter, TextRange?> CompositionRegionProperty =
+            AvaloniaProperty.RegisterDirect<TextPresenter, TextRange?>(
+                nameof(CompositionRegion),
+                o => o.CompositionRegion,
+                 (o, v) => o.CompositionRegion = v);
+
         /// <summary>
         /// Defines the <see cref="TextAlignment"/> property.
         /// </summary>
@@ -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);
+        }
+
         /// <summary>
         /// Gets or sets the font family.
         /// </summary>
@@ -548,7 +564,20 @@ namespace Avalonia.Controls.Presenters
 
             var foreground = Foreground;
 
-            if (!string.IsNullOrEmpty(_preeditText))
+            if(_compositionRegion != null)
+            {
+                var preeditHighlight = new ValueSpan<TextRunProperties>(_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<TextRunProperties>(_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();

+ 11 - 0
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)