From 391b5c2c11322237674075c65906259dacba5268 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 14 Sep 2022 17:33:39 +0200 Subject: [PATCH] Android - Initial attempt to fix focus management and implement SurroundingText --- .../Avalonia.Android/AndroidInputMethod.cs | 44 ++++++-- .../Platform/SkiaPlatform/TopLevelImpl.cs | 105 +++++++++++++++++- .../Input/TextInput/ITextInputMethodClient.cs | 8 ++ .../Presenters/TextPresenter.cs | 53 +++++++-- src/Avalonia.Controls/TextBox.cs | 2 +- .../TextBoxTextInputMethodClient.cs | 31 ++++-- 6 files changed, 214 insertions(+), 29 deletions(-) diff --git a/src/Android/Avalonia.Android/AndroidInputMethod.cs b/src/Android/Avalonia.Android/AndroidInputMethod.cs index 6d6944263f..cf966ba597 100644 --- a/src/Android/Avalonia.Android/AndroidInputMethod.cs +++ b/src/Android/Avalonia.Android/AndroidInputMethod.cs @@ -15,12 +15,12 @@ namespace Avalonia.Android internal interface IAndroidInputMethod { public ITextInputMethodClient Client { get; } - + public bool IsActive { get; } } - + class AndroidInputMethod : ITextInputMethodImpl, IAndroidInputMethod - where TView: View, IInitEditorInfo + where TView : View, IInitEditorInfo { private readonly TView _host; private readonly InputMethodManager _imm; @@ -51,16 +51,42 @@ namespace Avalonia.Android public void SetClient(ITextInputMethodClient client) { + if(client is null) + { + _inputConnection?.SetComposingText("", 0); + } + + if (_client != null) + { + _client.SurroundingTextChanged -= SurroundingTextChanged; + } + + Reset(); + _client = client; - + if (IsActive) { + _client.SurroundingTextChanged += SurroundingTextChanged; + _host.RequestFocus(); - Reset(); + _imm.ShowSoftInput(_host, ShowFlags.Implicit); } else - _imm.HideSoftInputFromWindow(_host.WindowToken, HideSoftInputFlags.None); + { + _imm.HideSoftInputFromWindow(_host.WindowToken, HideSoftInputFlags.None); + } + } + + private void SurroundingTextChanged(object sender, EventArgs e) + { + if (IsActive) + { + var surroundingText = Client.SurroundingText; + + _imm.UpdateSelection(_host, surroundingText.AnchorOffset, surroundingText.CursorOffset, surroundingText.AnchorOffset, surroundingText.CursorOffset); + } } public void SetCursorRect(Rect rect) @@ -69,10 +95,12 @@ namespace Avalonia.Android public void SetOptions(TextInputOptions options) { - _inputConnection = new InputConnectionImpl(_host, this); - _host.InitEditorInfo((_host, outAttrs) => { + _inputConnection?.CommitText("", 0); + + _inputConnection = new InputConnectionImpl(_host, this); + outAttrs.InputType = options.ContentType switch { TextInputContentType.Email => global::Android.Text.InputTypes.TextVariationEmailAddress, diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 6bb4256473..d88c81653c 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -13,6 +13,7 @@ using Avalonia.Android.Platform.Specific; using Avalonia.Android.Platform.Specific.Helpers; using Avalonia.Android.Platform.Storage; using Avalonia.Controls; +using Avalonia.Controls.Documents; using Avalonia.Controls.Platform; using Avalonia.Controls.Platform.Surfaces; using Avalonia.Input; @@ -25,6 +26,7 @@ using Avalonia.Platform.Storage; using Avalonia.Rendering; using Avalonia.Rendering.Composition; using Java.Lang; +using static System.Net.Mime.MediaTypeNames; namespace Avalonia.Android.Platform.SkiaPlatform { @@ -36,7 +38,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly AndroidKeyboardEventsHelper _keyboardHelper; private readonly AndroidMotionEventsHelper _pointerHelper; - private readonly ITextInputMethodImpl _textInputMethod; + private readonly AndroidInputMethod _textInputMethod; private ViewImpl _view; public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false) @@ -211,7 +213,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform } private Func _initEditorInfo; - protected InputConnectionImpl _inputConnection; public void InitEditorInfo(Func init) { @@ -257,7 +258,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform } } - internal class InputConnectionImpl: BaseInputConnection + internal class InputConnectionImpl: BaseInputConnection, IInputConnection { private readonly IAndroidInputMethod _inputMethod; @@ -271,11 +272,93 @@ namespace Avalonia.Android.Platform.SkiaPlatform { } + public string CompositionText { get; private set; } + + public override bool SetComposingRegion(int start, int end) + { + if (_inputMethod.IsActive) + { + var surroundingText = _inputMethod.Client.SurroundingText; + + System.Diagnostics.Debug.WriteLine($"Composing Region: [{start}|{end}] {surroundingText.Text?.Substring(start, end - start)}"); + } + + return base.SetComposingRegion(start, end); + } + + public override bool CommitCorrection(CorrectionInfo correctionInfo) + { + return base.CommitCorrection(correctionInfo); + } + + public override bool DeleteSurroundingTextInCodePoints(int beforeLength, int afterLength) + { + return base.DeleteSurroundingTextInCodePoints(beforeLength, afterLength); + } + + public override bool DeleteSurroundingText(int beforeLength, int afterLength) + { + if (_inputMethod.IsActive && _inputMethod.Client.SupportsSurroundingText) + { + _inputMethod.Client.DeleteSurroundingText(beforeLength, afterLength); + } + + return base.DeleteSurroundingText(beforeLength, afterLength); + } + + public override ICharSequence GetTextBeforeCursorFormatted(int length, [GeneratedEnum] GetTextFlags flags) + { + if (_inputMethod.IsActive) + { + var surroundingText = _inputMethod.Client.SurroundingText; + + if (_inputMethod.IsActive && !string.IsNullOrEmpty(surroundingText.Text)) + { + var start = System.Math.Max(surroundingText.CursorOffset - length, 0); + + var end = System.Math.Min(start + length, surroundingText.CursorOffset); + + var text = surroundingText.Text.Substring(start, end - start); + + System.Diagnostics.Debug.WriteLine($"Text Before: {text}"); + + return new Java.Lang.String(text); + } + } + + return base.GetTextBeforeCursorFormatted(length, flags); + } + + public override ICharSequence GetTextAfterCursorFormatted(int length, [GeneratedEnum] GetTextFlags flags) + { + if (_inputMethod.IsActive) + { + var surroundingText = _inputMethod.Client.SurroundingText; + + if (_inputMethod.IsActive && !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}"); + + return new Java.Lang.String(text); + } + } + + return base.GetTextAfterCursorFormatted(length, flags); + } + public override bool SetComposingText(ICharSequence text, int newCursorPosition) { + CompositionText = text.ToString(); + if (_inputMethod.IsActive) { - _inputMethod.Client.SetPreeditText(text.ToString()); + _inputMethod.Client.SetPreeditText(CompositionText); } return base.SetComposingText(text, newCursorPosition); @@ -283,6 +366,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform public override bool CommitText(ICharSequence text, int newCursorPosition) { + CompositionText = null; + if (_inputMethod.IsActive) { _inputMethod.Client.SetPreeditText(null); @@ -290,5 +375,17 @@ namespace Avalonia.Android.Platform.SkiaPlatform return base.CommitText(text, newCursorPosition); } + + public override bool FinishComposingText() + { + CompositionText = null; + + if (_inputMethod.IsActive) + { + _inputMethod.Client.SetPreeditText(CompositionText); + } + + return base.FinishComposingText(); + } } } diff --git a/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs b/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs index 48a9c52eb5..a9532e6429 100644 --- a/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs +++ b/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs @@ -41,6 +41,14 @@ namespace Avalonia.Input.TextInput /// Should be fired when surrounding text changed /// event EventHandler? SurroundingTextChanged; + + /// + /// Deletes specified length before and after current selection. + /// + /// The length before current selection. + /// The length after current selection. + void DeleteSurroundingText(int beforeLength, int afterLength); + /// /// Returns the text before the cursor. Must return a non-empty string if cursor is not at the beginning of the text entry /// diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 0d8fd75205..b0b12d1ff9 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -9,7 +9,6 @@ using Avalonia.VisualTree; using Avalonia.Layout; using Avalonia.Media.Immutable; using Avalonia.Controls.Documents; -using Avalonia.Media.TextFormatting.Unicode; namespace Avalonia.Controls.Presenters { @@ -100,6 +99,7 @@ namespace Avalonia.Controls.Presenters private Rect _caretBounds; private Point _navigationPosition; private string? _preeditText; + private CharacterHit _compositionStartHit = new CharacterHit(-1); static TextPresenter() { @@ -235,7 +235,7 @@ namespace Avalonia.Controls.Presenters _textLayout = CreateTextLayout(); - UpdateCaret(_lastCharacterHit); + UpdateCaret(_lastCharacterHit, false); return _textLayout; } @@ -532,8 +532,8 @@ namespace Avalonia.Controls.Presenters { var preeditHighlight = new ValueSpan(_caretIndex, _preeditText.Length, new GenericTextRunProperties(typeface, FontSize, - foregroundBrush: foreground/*, - textDecorations: TextDecorations.Underline*/)); + foregroundBrush: foreground, + textDecorations: TextDecorations.Underline)); textStyleOverrides = new[] { @@ -573,7 +573,6 @@ namespace Avalonia.Controls.Presenters InvalidateMeasure(); } - protected override Size MeasureOverride(Size availableSize) { _constraint = availableSize; @@ -800,7 +799,7 @@ namespace Avalonia.Controls.Presenters CaretChanged(); } - private void UpdateCaret(CharacterHit characterHit) + private void UpdateCaret(CharacterHit characterHit, bool updateCaretIndex = true) { _lastCharacterHit = characterHit; @@ -828,7 +827,10 @@ namespace Avalonia.Controls.Presenters CaretBoundsChanged?.Invoke(this, EventArgs.Empty); } - SetAndRaise(CaretIndexProperty, ref _caretIndex, caretIndex); + if (updateCaretIndex) + { + SetAndRaise(CaretIndexProperty, ref _caretIndex, caretIndex); + } } internal Rect GetCursorRectangle() @@ -845,12 +847,48 @@ namespace Avalonia.Controls.Presenters _caretTimer.Tick -= CaretTimerTick; } + protected void OnPreeditTextChanged(string? preeditText) + { + InvalidateTextLayout(); + + if (preeditText is null) + { + _compositionStartHit = new CharacterHit(-1); + } + else + { + if (_compositionStartHit.FirstCharacterIndex == -1) + { + _compositionStartHit = _lastCharacterHit; + } + } + + if (_compositionStartHit.FirstCharacterIndex != -1) + { + var textPosition = _compositionStartHit.FirstCharacterIndex + _compositionStartHit.TrailingLength + preeditText?.Length ?? 0; + + var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(textPosition, true); + + var textLine = TextLayout.TextLines[lineIndex]; + + var characterHit = textLine.GetNextCaretCharacterHit(new CharacterHit(textPosition - 1)); + + UpdateCaret(characterHit, false); + } + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); switch (change.Property.Name) { + case nameof(PreeditText): + { + OnPreeditTextChanged(change.NewValue as string); + break; + } + case nameof(Foreground): case nameof(FontSize): case nameof(FontStyle): @@ -859,7 +897,6 @@ namespace Avalonia.Controls.Presenters case nameof(FontStretch): case nameof(Text): - case nameof(PreeditText): case nameof(TextAlignment): case nameof(TextWrapping): diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 77880773b6..d14074a2ff 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -1482,7 +1482,7 @@ namespace Avalonia.Controls SelectionEnd = Text?.Length ?? 0; } - private bool DeleteSelection(bool raiseTextChanged = true) + internal bool DeleteSelection(bool raiseTextChanged = true) { if (IsReadOnly) return true; diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 67966985fa..d41a397c12 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Avalonia.Controls.Presenters; using Avalonia.Input; using Avalonia.Input.TextInput; +using Avalonia.Media; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -56,7 +57,7 @@ namespace Avalonia.Controls public TextInputMethodSurroundingText SurroundingText => new() { Text = _presenter?.Text ?? "", - CursorOffset = _presenter?.CaretIndex ?? 0, + CursorOffset = _presenter?.SelectionEnd ?? 0, AnchorOffset = _presenter?.SelectionStart ?? 0 }; @@ -78,10 +79,14 @@ namespace Avalonia.Controls { if (e.Property == TextBox.TextProperty || e.Property == TextBox.SelectionStartProperty || e.Property == TextBox.SelectionEndProperty) - SurroundingTextChanged?.Invoke(this, EventArgs.Empty); + { + if (string.IsNullOrEmpty(_presenter?.PreeditText)) + { + SurroundingTextChanged?.Invoke(this, EventArgs.Empty); + } + } } - public void SetPresenter(TextPresenter? presenter, TextBox? parent) { if (_parent != null) @@ -98,6 +103,8 @@ namespace Avalonia.Controls if (_presenter != null) { + _presenter.PreeditText = null; + _presenter.CaretBoundsChanged -= OnCaretBoundsChanged; } @@ -107,14 +114,22 @@ namespace Avalonia.Controls { _presenter.CaretBoundsChanged += OnCaretBoundsChanged; } + + TextViewVisualChanged?.Invoke(this, EventArgs.Empty); + CursorRectangleChanged?.Invoke(this, EventArgs.Empty); + } - if(presenter == null) + public void DeleteSurroundingText(int beforeLength, int afterLength) + { + if (_parent != null && _presenter != null && string.IsNullOrEmpty(_presenter.PreeditText)) { - SetPreeditText(null); + var start = _presenter.SelectionStart; + + _parent.SelectionStart = start - beforeLength; + _parent.SelectionEnd = start + afterLength; + + _parent.DeleteSelection(true); } - - TextViewVisualChanged?.Invoke(this, EventArgs.Empty); - CursorRectangleChanged?.Invoke(this, EventArgs.Empty); } } }