From 006fe37d0dc0568022484e967d8ddbe8c2233d1c Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 12 Sep 2022 00:41:06 +0300 Subject: [PATCH] Working on iOS IME --- samples/MobileSandbox.iOS/AppDelegate.cs | 5 + samples/MobileSandbox.iOS/Info.plist | 2 +- .../MobileSandbox.iOS.csproj | 2 +- .../Input/TextInput/ITextInputMethodClient.cs | 2 + .../Input/TextInput/ITextInputMethodImpl.cs | 4 - .../TextBoxTextInputMethodClient.cs | 34 +- src/iOS/Avalonia.iOS/Avalonia.iOS.csproj | 2 +- src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs | 4 +- src/iOS/Avalonia.iOS/AvaloniaView.Text.cs | 504 +----------------- src/iOS/Avalonia.iOS/AvaloniaView.cs | 113 ---- src/iOS/Avalonia.iOS/CombinedSpan3.cs | 40 ++ .../TextInputResponder.Properties.cs | 71 +++ src/iOS/Avalonia.iOS/TextInputResponder.cs | 471 ++++++++++++++++ 13 files changed, 655 insertions(+), 599 deletions(-) create mode 100644 src/iOS/Avalonia.iOS/CombinedSpan3.cs create mode 100644 src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs create mode 100644 src/iOS/Avalonia.iOS/TextInputResponder.cs diff --git a/samples/MobileSandbox.iOS/AppDelegate.cs b/samples/MobileSandbox.iOS/AppDelegate.cs index da472be877..60214cc541 100644 --- a/samples/MobileSandbox.iOS/AppDelegate.cs +++ b/samples/MobileSandbox.iOS/AppDelegate.cs @@ -3,6 +3,7 @@ using UIKit; using Avalonia; using Avalonia.Controls; using Avalonia.iOS; +using Avalonia.Logging; using Avalonia.Media; namespace MobileSandbox @@ -13,5 +14,9 @@ namespace MobileSandbox [Register("AppDelegate")] public partial class AppDelegate : AvaloniaAppDelegate { + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + return builder.LogToTrace(LogEventLevel.Debug, "IOSIME"); + } } } diff --git a/samples/MobileSandbox.iOS/Info.plist b/samples/MobileSandbox.iOS/Info.plist index 6ffe3ba662..ff939d5592 100644 --- a/samples/MobileSandbox.iOS/Info.plist +++ b/samples/MobileSandbox.iOS/Info.plist @@ -13,7 +13,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.0 UIDeviceFamily 1 diff --git a/samples/MobileSandbox.iOS/MobileSandbox.iOS.csproj b/samples/MobileSandbox.iOS/MobileSandbox.iOS.csproj index 6d24fea1cf..1bb12652d0 100644 --- a/samples/MobileSandbox.iOS/MobileSandbox.iOS.csproj +++ b/samples/MobileSandbox.iOS/MobileSandbox.iOS.csproj @@ -3,7 +3,7 @@ Exe manual net6.0-ios - 10.0 + 13.0 True iossimulator-x64 diff --git a/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs b/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs index 5af8fe0251..48a9c52eb5 100644 --- a/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs +++ b/src/Avalonia.Base/Input/TextInput/ITextInputMethodClient.cs @@ -49,6 +49,8 @@ namespace Avalonia.Input.TextInput /// Returns the text before the cursor. Must return a non-empty string if cursor is not at the end of the text entry /// string? TextAfterCursor { get; } + + public void SelectInSurroundingText(int start, int end); } public struct TextInputMethodSurroundingText diff --git a/src/Avalonia.Base/Input/TextInput/ITextInputMethodImpl.cs b/src/Avalonia.Base/Input/TextInput/ITextInputMethodImpl.cs index 6f6ef403f7..be7ad81f81 100644 --- a/src/Avalonia.Base/Input/TextInput/ITextInputMethodImpl.cs +++ b/src/Avalonia.Base/Input/TextInput/ITextInputMethodImpl.cs @@ -5,10 +5,6 @@ namespace Avalonia.Input.TextInput [Unstable] public interface ITextInputMethodImpl { - ITextInputMethodClient? Client { get; } - - bool IsActive { get; } - void SetClient(ITextInputMethodClient? client); void SetCursorRect(Rect rect); void SetOptions(TextInputOptions options); diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 8ee4def676..67966985fa 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -51,21 +51,51 @@ namespace Avalonia.Controls public bool SupportsSurroundingText => true; - public event EventHandler? SurroundingTextChanged { add { } remove { } } + public event EventHandler? SurroundingTextChanged; - public TextInputMethodSurroundingText SurroundingText => new TextInputMethodSurroundingText { Text = _presenter?.Text ?? "", CursorOffset = _presenter?.CaretIndex ?? 0, AnchorOffset = _presenter?.SelectionStart ?? 0}; + public TextInputMethodSurroundingText SurroundingText => new() + { + Text = _presenter?.Text ?? "", + CursorOffset = _presenter?.CaretIndex ?? 0, + AnchorOffset = _presenter?.SelectionStart ?? 0 + }; public string? TextBeforeCursor => null; public string? TextAfterCursor => null; + public void SelectInSurroundingText(int start, int end) + { + if(_parent == null) + return; + // TODO: Account for the offset + _parent.SelectionStart = start; + _parent.SelectionEnd = end; + } private void OnCaretBoundsChanged(object? sender, EventArgs e) => CursorRectangleChanged?.Invoke(this, EventArgs.Empty); + + private void OnTextBoxPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == TextBox.TextProperty || e.Property == TextBox.SelectionStartProperty || + e.Property == TextBox.SelectionEndProperty) + SurroundingTextChanged?.Invoke(this, EventArgs.Empty); + } public void SetPresenter(TextPresenter? presenter, TextBox? parent) { + if (_parent != null) + { + _parent.PropertyChanged -= OnTextBoxPropertyChanged; + } + _parent = parent; + if (_parent != null) + { + _parent.PropertyChanged += OnTextBoxPropertyChanged; + } + if (_presenter != null) { _presenter.CaretBoundsChanged -= OnCaretBoundsChanged; diff --git a/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj b/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj index ea3a79941d..e05566c454 100644 --- a/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj +++ b/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj @@ -1,7 +1,7 @@  net6.0-ios - 10.0 + 13.0 true diff --git a/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs b/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs index b8889a4182..b605d82541 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaAppDelegate.cs @@ -20,7 +20,7 @@ namespace Avalonia.iOS } } - protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseiOS(); + protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder; [Export("window")] public UIWindow Window { get; set; } @@ -28,7 +28,7 @@ namespace Avalonia.iOS [Export("application:didFinishLaunchingWithOptions:")] public bool FinishedLaunching(UIApplication application, NSDictionary launchOptions) { - var builder = AppBuilder.Configure(); + var builder = AppBuilder.Configure().UseiOS(); CustomizeAppBuilder(builder); var lifetime = new SingleViewLifetime(); diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.Text.cs b/src/iOS/Avalonia.iOS/AvaloniaView.Text.cs index 50f5b26728..fb0857e472 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.Text.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.Text.cs @@ -1,500 +1,54 @@ -using System; -using System.Runtime.InteropServices; -using Foundation; -using ObjCRuntime; +#nullable enable using Avalonia.Input.TextInput; -using Avalonia.Input; -using Avalonia.Input.Raw; -using CoreGraphics; +using JetBrains.Annotations; using UIKit; namespace Avalonia.iOS; -#nullable enable - -[Adopts("UITextInput")] -[Adopts("UITextInputTraits")] -[Adopts("UIKeyInput")] -public class AvaloniaResponder : UIResponder, IUITextInput +public partial class AvaloniaView { - private class AvaloniaTextRange : UITextRange - { - private readonly NSRange _range; - - public AvaloniaTextRange(NSRange range) - { - _range = range; - } - - public override AvaloniaTextPosition Start => new AvaloniaTextPosition((int)_range.Location); - - public override AvaloniaTextPosition End => new AvaloniaTextPosition((int)(_range.Location + _range.Length)); - - public NSRange Range => _range; - - public override bool IsEmpty => _range.Length == 0; - } - - private class AvaloniaTextPosition : UITextPosition - { - public AvaloniaTextPosition(nint index) - { - Index = index; - } - - public nint Index { get; } - - //[Export("inputDelegate")] - //public UITextInputDelegate InputDelegate { get; set; } - - [Export("foo")] - public void Foo() - { - - } - } - - private UIResponder _nextResponder; - - public AvaloniaResponder(AvaloniaView view, ITextInputMethodClient client) - { - _nextResponder = view; - _client = client; - _tokenizer = new UITextInputStringTokenizer(this); - - } - - public override UIResponder NextResponder => _nextResponder; - - public override bool CanPerform(Selector action, NSObject? withSender) - { - return base.CanPerform(action, withSender); - } - - private string _markedText = ""; - private ITextInputMethodClient? _client; - private NSDictionary? _markedTextStyle; - private readonly UITextPosition _beginningOfDocument = new AvaloniaTextPosition(0); - private readonly UITextInputStringTokenizer _tokenizer; - - public ITextInputMethodClient? Client => _client; - - public bool IsActive => _client != null; - - public override bool CanResignFirstResponder => true; - - public override bool CanBecomeFirstResponder => true; - - public override UIEditingInteractionConfiguration EditingInteractionConfiguration => - UIEditingInteractionConfiguration.Default; - - public override NSString TextInputContextIdentifier => new NSString("Test"); - - public override UITextInputMode TextInputMode => UITextInputMode.CurrentInputMode; - - [DllImport("/usr/lib/libobjc.dylib")] - extern static void objc_msgSend(IntPtr receiver, IntPtr selector, IntPtr arg); - - private static readonly IntPtr SelectionWillChange = Selector.GetHandle("selectionWillChange:"); - private static readonly IntPtr SelectionDidChange = Selector.GetHandle("selectionDidChange:"); - private static readonly IntPtr TextWillChange = Selector.GetHandle("textWillChange:"); - private static readonly IntPtr TextDidChange = Selector.GetHandle("textDidChange:"); - - private void ClientOnCursorRectangleChanged(object? sender, EventArgs e) - { - objc_msgSend(WeakInputDelegate.Handle.Handle, SelectionWillChange, this.Handle.Handle); - objc_msgSend(WeakInputDelegate.Handle.Handle, SelectionDidChange, this.Handle.Handle); - } - - private void ClientOnSurroundingTextChanged(object? sender, EventArgs e) - { - objc_msgSend(WeakInputDelegate.Handle.Handle, TextWillChange, Handle.Handle); - objc_msgSend(WeakInputDelegate.Handle.Handle, TextDidChange, Handle.Handle); - } - - // Traits (Optional) - [Export("autocapitalizationType")] public UITextAutocapitalizationType AutocapitalizationType { get; private set; } - - [Export("autocorrectionType")] public UITextAutocorrectionType AutocorrectionType { get; private set; } - [Export("keyboardType")] public UIKeyboardType KeyboardType { get; private set; } = UIKeyboardType.Default; - - [Export("keyboardAppearance")] - public UIKeyboardAppearance KeyboardAppearance { get; private set; } = UIKeyboardAppearance.Default; - - [Export("returnKeyType")] public UIReturnKeyType ReturnKeyType { get; set; } - - [Export("enablesReturnKeyAutomatically")] public bool EnablesReturnKeyAutomatically { get; set; } - [Export("isSecureTextEntry")] public bool IsSecureEntry { get; private set; } - - [Export("spellCheckingType")] - public UITextSpellCheckingType SpellCheckingType { get; set; } = UITextSpellCheckingType.Default; - - [Export("textContentType")] - public NSString TextContentType { get; set; } - - [Export("smartQuotesType")] - public UITextSmartQuotesType SmartQuotesType { get; set; } = UITextSmartQuotesType.Default; - - [Export("smartDashesType")] - public UITextSmartDashesType SmartDashesType { get; set; } = UITextSmartDashesType.Default; - - [Export("smartInsertDeleteType")] - public UITextSmartInsertDeleteType SmartInsertDeleteType { get; set; } = UITextSmartInsertDeleteType.Default; - - [Export("passwordRules")] - public UITextInputPasswordRules PasswordRules { get; set; } - - void IUIKeyInput.InsertText(string text) - { - if (_client == null) - { - return; - } - - if (text == "\n") - { - // emulate return key released. - } - - switch (ReturnKeyType) - { - case UIReturnKeyType.Done: - case UIReturnKeyType.Search: - case UIReturnKeyType.Go: - case UIReturnKeyType.Send: - ResignFirstResponder(); - return; - } - - // TODO replace this with _client.SetCommitText? - if (KeyboardDevice.Instance is { }) - { - /*_topLevelImpl.Input?.Invoke(new RawTextInputEventArgs(KeyboardDevice.Instance, - 0, InputRoot, text));*/ - } - } - - void IUIKeyInput.DeleteBackward() - { - if (KeyboardDevice.Instance is { }) - { - // TODO: pass this through IME infrastructure instead of emulating a backspace press - /*_topLevelImpl.Input?.Invoke(new RawKeyEventArgs(KeyboardDevice.Instance, - 0, InputRoot, RawKeyEventType.KeyDown, Key.Back, RawInputModifiers.None)); - - _topLevelImpl.Input?.Invoke(new RawKeyEventArgs(KeyboardDevice.Instance, - 0, InputRoot, RawKeyEventType.KeyUp, Key.Back, RawInputModifiers.None));*/ - } - } - - bool IUIKeyInput.HasText => true; - - string IUITextInput.TextInRange(UITextRange range) - { - var text = _client.SurroundingText.Text; - - if (!string.IsNullOrWhiteSpace(_markedText)) - { - // todo check this combining _marked text with surrounding text. - int cursorPos = _client.SurroundingText.CursorOffset; - text = text[.. cursorPos] + _markedText + text[cursorPos ..]; - } - - var start = (int)(range.Start as AvaloniaTextPosition).Index; - var end = (int)(range.End as AvaloniaTextPosition).Index; - - var result = text[start .. end]; - - return result; - } - - void IUITextInput.ReplaceText(UITextRange range, string text) - { - ((IUITextInput)this).SelectedTextRange = range; - - // todo _client.SetCommitText(text); - if (KeyboardDevice.Instance is { }) - { - /*_topLevelImpl.Input?.Invoke(new RawTextInputEventArgs(KeyboardDevice.Instance, - 0, InputRoot, text));*/ - } - } - - void IUITextInput.SetMarkedText(string markedText, NSRange selectedRange) - { - _markedText = markedText; - - // todo check this... seems correct - _client.SetPreeditText(markedText); - } - - void IUITextInput.UnmarkText() - { - if (string.IsNullOrWhiteSpace(_markedText)) - return; - - // todo _client.CommitString (_markedText); - - _markedText = ""; - } - - public UITextRange GetTextRange(UITextPosition fromPosition, UITextPosition toPosition) - { - if (fromPosition is AvaloniaTextPosition f && toPosition is AvaloniaTextPosition t) - { - // todo check calculation. - var range = new NSRange(Math.Min(f.Index, t.Index), Math.Abs(t.Index - f.Index)); - return new AvaloniaTextRange(range); - } - - return null; - //throw new Exception(); - } - - UITextPosition IUITextInput.GetPosition(UITextPosition fromPosition, nint offset) - { - if (fromPosition is AvaloniaTextPosition indexedPosition) - { - var end = indexedPosition.Index + offset; - // Verify position is valid in document. - //if (end > self.text.length || end < 0) { - // return nil; - //} - - return new AvaloniaTextPosition(end); - } - - return null; - } - - UITextPosition IUITextInput.GetPosition(UITextPosition fromPosition, UITextLayoutDirection inDirection, nint offset) - { - if (fromPosition is AvaloniaTextPosition f) - { - var newPosition = f.Index; - - switch (inDirection) - { - case UITextLayoutDirection.Left: - newPosition -= offset; - break; - - case UITextLayoutDirection.Right: - newPosition += offset; - break; - } - - if (newPosition < 0) - { - newPosition = 0; - } - - if (newPosition > _client.SurroundingText.Text.Length) - { - newPosition = _client.SurroundingText.Text.Length; - } - - return new AvaloniaTextPosition(newPosition); - } - - throw new Exception(); - } - - NSComparisonResult IUITextInput.ComparePosition(UITextPosition first, UITextPosition second) - { - if (first is AvaloniaTextPosition f && second is AvaloniaTextPosition s) - { - if (f.Index < s.Index) - return NSComparisonResult.Ascending; - - if (f.Index > s.Index) - return NSComparisonResult.Descending; - - return NSComparisonResult.Same; - } - - throw new Exception(); - } - - nint IUITextInput.GetOffsetFromPosition(UITextPosition fromPosition, UITextPosition toPosition) - { - if (fromPosition is AvaloniaTextPosition f && toPosition is AvaloniaTextPosition t) - { - return t.Index - f.Index; - } - - throw new Exception(); - } - - UITextPosition IUITextInput.GetPositionWithinRange(UITextRange range, UITextLayoutDirection direction) - { - if (range is AvaloniaTextRange indexedRange) - { - nint position = 0; - - switch (direction) - { - case UITextLayoutDirection.Up: - case UITextLayoutDirection.Left: - position = indexedRange.Range.Location; - break; - - case UITextLayoutDirection.Down: - case UITextLayoutDirection.Right: - position = indexedRange.Range.Location + indexedRange.Range.Length; - break; - } - - return new AvaloniaTextPosition(position); - } - - throw new Exception(); - } - - UITextRange IUITextInput.GetCharacterRange(UITextPosition byExtendingPosition, UITextLayoutDirection direction) - { - if (byExtendingPosition is AvaloniaTextPosition pos) - { - NSRange result = new NSRange(); - - switch (direction) - { - case UITextLayoutDirection.Up: - case UITextLayoutDirection.Left: - result = new NSRange(pos.Index - 1, 1); - break; - - case UITextLayoutDirection.Right: - case UITextLayoutDirection.Down: - result = new NSRange(pos.Index, 1); - break; - } - - return new AvaloniaTextRange(result); - } + private const string ImeLog = "IOSIME"; + private Rect _cursorRect; + private TextInputOptions? _options; - throw new Exception(); - } - - NSWritingDirection IUITextInput.GetBaseWritingDirection(UITextPosition forPosition, - UITextStorageDirection direction) - { - return NSWritingDirection.LeftToRight; - - // todo query and retyrn RTL. - } - - void IUITextInput.SetBaseWritingDirectionforRange(NSWritingDirection writingDirection, UITextRange range) - { - // todo ? ignore? - } - - CGRect IUITextInput.GetFirstRectForRange(UITextRange range) - { - if (_client == null) - return CGRect.Empty; - - if (!string.IsNullOrWhiteSpace(_markedText)) - { - return CGRect.Empty; - } - - if (range is AvaloniaTextRange r) - { - // todo add ime apis to get cursor rect. - throw new NotImplementedException(); - } - - throw new Exception(); - } - - CGRect IUITextInput.GetCaretRectForPosition(UITextPosition? position) - { - var rect = _client.CursorRectangle; - - return new CGRect(rect.X, rect.Y, rect.Width, rect.Height); - } - - UITextPosition IUITextInput.GetClosestPositionToPoint(CGPoint point) - { - // TODO HitTest text? - throw new System.NotImplementedException(); - } - - UITextPosition IUITextInput.GetClosestPositionToPoint(CGPoint point, UITextRange withinRange) - { - // TODO HitTest text? - throw new System.NotImplementedException(); - } - - UITextRange IUITextInput.GetCharacterRangeAtPoint(CGPoint point) - { - return null; - } - - UITextSelectionRect[] IUITextInput.GetSelectionRects(UITextRange range) + private static UIResponder? CurrentAvaloniaResponder { get; set; } + public override bool BecomeFirstResponder() { - return null; + var res = base.BecomeFirstResponder(); + if (res) + CurrentAvaloniaResponder = this; + return res; } - [Export("textStylingAtPosition:inDirection:")] - public NSDictionary GetTextStylingAtPosition(UITextPosition position, UITextStorageDirection direction) + public override bool ResignFirstResponder() { - return null; + var res = base.ResignFirstResponder(); + if (res && ReferenceEquals(CurrentAvaloniaResponder, this)) + CurrentAvaloniaResponder = null; + return res; } - UITextRange? IUITextInput.SelectedTextRange - { - get - { - return new AvaloniaTextRange(new NSRange( - (nint)Math.Min(_client.SurroundingText.CursorOffset, _client.SurroundingText.AnchorOffset), - (nint)Math.Abs(_client.SurroundingText.CursorOffset - _client.SurroundingText.AnchorOffset))); - } - set - { - throw new NotImplementedException(); - } - } + private bool IsDrivingText => CurrentAvaloniaResponder is TextInputResponder t && ReferenceEquals(t.NextResponder, this); - NSDictionary? IUITextInput.MarkedTextStyle + void ITextInputMethodImpl.SetClient(ITextInputMethodClient? client) { - get => _markedTextStyle; - set => _markedTextStyle = value; - } - - UITextPosition IUITextInput.BeginningOfDocument => _beginningOfDocument; + _client = client; + if (_client == null && IsDrivingText) + BecomeFirstResponder(); - UITextPosition IUITextInput.EndOfDocument - { - get + if (_client is { }) { - return new AvaloniaTextPosition(_client.SurroundingText.Text.Length + _markedText.Length); + new TextInputResponder(this, _client).BecomeFirstResponder(); } } + void ITextInputMethodImpl.SetCursorRect(Rect rect) => _cursorRect = rect; - public NSObject? WeakInputDelegate - { - get; - set; - } - - NSObject IUITextInput.WeakTokenizer => _tokenizer; + void ITextInputMethodImpl.SetOptions(TextInputOptions options) => _options = options; - UITextRange IUITextInput.MarkedTextRange + void ITextInputMethodImpl.Reset() { - get - { - if (_client == null || string.IsNullOrWhiteSpace(_markedText)) - { - return null; - } - - // todo - return new AvaloniaTextRange(new NSRange( - (nint)Math.Min(_client.SurroundingText.CursorOffset, _client.SurroundingText.AnchorOffset), - (nint)Math.Abs(_client.SurroundingText.CursorOffset - _client.SurroundingText.AnchorOffset))); - } + if (IsDrivingText) + BecomeFirstResponder(); } } diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 0038a03911..1c79e93f5c 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -26,7 +26,6 @@ namespace Avalonia.iOS private EmbeddableControlRoot _topLevel; private TouchHandler _touches; private ITextInputMethodClient _client; - private bool _isActive; public AvaloniaView() { @@ -155,117 +154,5 @@ namespace Avalonia.iOS get => (Control)_topLevel.Content; set => _topLevel.Content = value; } - - ITextInputMethodClient ITextInputMethodImpl.Client => _client; - - bool ITextInputMethodImpl.IsActive => _isActive; - - private AvaloniaResponder _currentResponder; - - void ITextInputMethodImpl.SetClient(ITextInputMethodClient client) - { - if (_client != null) - { - //_client.CursorRectangleChanged -= ClientOnCursorRectangleChanged; - } - - _client = client; - - if (_client is { }) - { - if (_currentResponder != null && _currentResponder.IsFirstResponder) - { - _currentResponder.ResignFirstResponder(); - _currentResponder = null; - } - - _currentResponder = new AvaloniaResponder(this, _client); - //_client.CursorRectangleChanged += ClientOnCursorRectangleChanged; - - //_client.SurroundingTextChanged += ClientOnSurroundingTextChanged; - _currentResponder.BecomeFirstResponder(); - - var x = _currentResponder.IsFirstResponder; - } - else - { - _currentResponder.ResignFirstResponder(); - } - } - - void ITextInputMethodImpl.SetCursorRect(Rect rect) - { - // maybe this will be cursor / selection rect? - } - - void ITextInputMethodImpl.SetOptions(TextInputOptions options) - { - /*IsSecureEntry = false; - - switch (options.ContentType) - { - case TextInputContentType.Normal: - KeyboardType = UIKeyboardType.Default; - break; - - case TextInputContentType.Alpha: - KeyboardType = UIKeyboardType.AsciiCapable; - break; - - case TextInputContentType.Digits: - KeyboardType = UIKeyboardType.PhonePad; - break; - - case TextInputContentType.Pin: - KeyboardType = UIKeyboardType.NumberPad; - IsSecureEntry = true; - break; - - case TextInputContentType.Number: - KeyboardType = UIKeyboardType.PhonePad; - break; - - case TextInputContentType.Email: - KeyboardType = UIKeyboardType.EmailAddress; - break; - - case TextInputContentType.Url: - KeyboardType = UIKeyboardType.Url; - break; - - case TextInputContentType.Name: - KeyboardType = UIKeyboardType.NamePhonePad; - break; - - case TextInputContentType.Password: - KeyboardType = UIKeyboardType.Default; - IsSecureEntry = true; - break; - - case TextInputContentType.Social: - KeyboardType = UIKeyboardType.Twitter; - break; - - case TextInputContentType.Search: - KeyboardType = UIKeyboardType.WebSearch; - break; - } - - if (options.IsSensitive) - { - IsSecureEntry = true; - } - - ReturnKeyType = (UIReturnKeyType)options.ReturnKeyType; - AutocorrectionType = UITextAutocorrectionType.Yes; - SpellCheckingType = UITextSpellCheckingType.Yes; - KeyboardAppearance = UIKeyboardAppearance.Alert;*/ - } - - - void ITextInputMethodImpl.Reset() - { - _currentResponder?.ResignFirstResponder(); - } } } diff --git a/src/iOS/Avalonia.iOS/CombinedSpan3.cs b/src/iOS/Avalonia.iOS/CombinedSpan3.cs new file mode 100644 index 0000000000..29d8eb3955 --- /dev/null +++ b/src/iOS/Avalonia.iOS/CombinedSpan3.cs @@ -0,0 +1,40 @@ +using System; +using Avalonia.Controls.Documents; + +namespace Avalonia.iOS; + +internal ref struct CombinedSpan3 +{ + public ReadOnlySpan Span1, Span2, Span3; + + public CombinedSpan3(ReadOnlySpan span1, ReadOnlySpan span2, ReadOnlySpan span3) + { + Span1 = span1; + Span2 = span2; + Span3 = span3; + } + + public int Length => Span1.Length + Span2.Length + Span3.Length; + + void CopyFromSpan(ReadOnlySpan from, ref int offset, ref Span to) + { + if(to.Length == 0) + return; + if (offset < from.Length) + { + var copyNow = Math.Min(from.Length - offset, to.Length); + from.Slice(offset, copyNow).CopyTo(to); + to = to.Slice(copyNow); + offset = 0; + } + else + offset -= from.Length; + } + + public void CopyTo(Span to, int offset) + { + CopyFromSpan(Span1, ref offset, ref to); + CopyFromSpan(Span2, ref offset, ref to); + CopyFromSpan(Span3, ref offset, ref to); + } +} diff --git a/src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs b/src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs new file mode 100644 index 0000000000..cd9433056a --- /dev/null +++ b/src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs @@ -0,0 +1,71 @@ +#nullable enable +using Avalonia.Input.TextInput; +using Foundation; +using UIKit; + +namespace Avalonia.iOS; + +partial class AvaloniaView +{ + partial class TextInputResponder + { + [Export("autocapitalizationType")] + public UITextAutocapitalizationType AutocapitalizationType { get; private set; } + + [Export("autocorrectionType")] + public UITextAutocorrectionType AutocorrectionType => UITextAutocorrectionType.Yes; + + [Export("keyboardType")] + public UIKeyboardType KeyboardType => + _view._options == null ? + UIKeyboardType.Default : + _view._options.ContentType switch + { + TextInputContentType.Alpha => UIKeyboardType.AsciiCapable, + TextInputContentType.Digits or TextInputContentType.Number => UIKeyboardType.NumberPad, + TextInputContentType.Pin => UIKeyboardType.NumberPad, + TextInputContentType.Email => UIKeyboardType.EmailAddress, + TextInputContentType.Url => UIKeyboardType.Url, + TextInputContentType.Name => UIKeyboardType.NamePhonePad, + TextInputContentType.Social => UIKeyboardType.Twitter, + TextInputContentType.Search => UIKeyboardType.WebSearch, + _ => UIKeyboardType.Default + }; + + [Export("keyboardAppearance")] + public UIKeyboardAppearance KeyboardAppearance => UIKeyboardAppearance.Alert; + + [Export("returnKeyType")] public UIReturnKeyType ReturnKeyType => UIReturnKeyType.Default; + + [Export("enablesReturnKeyAutomatically")] + public bool EnablesReturnKeyAutomatically { get; set; } + + [Export("isSecureTextEntry")] public bool IsSecureEntry => + _view._options?.ContentType is TextInputContentType.Password or TextInputContentType.Pin + || (_view._options?.IsSensitive ?? false); + + [Export("spellCheckingType")] public UITextSpellCheckingType SpellCheckingType => UITextSpellCheckingType.Yes; + + [Export("textContentType")] public NSString TextContentType { get; set; } = new NSString("text/plain"); + + [Export("smartQuotesType")] + public UITextSmartQuotesType SmartQuotesType { get; set; } = UITextSmartQuotesType.Default; + + [Export("smartDashesType")] + public UITextSmartDashesType SmartDashesType { get; set; } = UITextSmartDashesType.Default; + + [Export("smartInsertDeleteType")] + public UITextSmartInsertDeleteType SmartInsertDeleteType { get; set; } = UITextSmartInsertDeleteType.Default; + + [Export("passwordRules")] public UITextInputPasswordRules PasswordRules { get; set; } = null!; + + public NSObject? WeakInputDelegate + { + get; + set; + } + + NSObject IUITextInput.WeakTokenizer => _tokenizer; + + } +} diff --git a/src/iOS/Avalonia.iOS/TextInputResponder.cs b/src/iOS/Avalonia.iOS/TextInputResponder.cs new file mode 100644 index 0000000000..51db7bf89e --- /dev/null +++ b/src/iOS/Avalonia.iOS/TextInputResponder.cs @@ -0,0 +1,471 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Foundation; +using ObjCRuntime; +using Avalonia.Input.TextInput; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Logging; +using CoreGraphics; +using UIKit; +// ReSharper disable InconsistentNaming +// ReSharper disable StringLiteralTypo + +namespace Avalonia.iOS; + +#nullable enable + +partial class AvaloniaView +{ + + [Adopts("UITextInput")] + [Adopts("UITextInputTraits")] + [Adopts("UIKeyInput")] + partial class TextInputResponder : UIResponder, IUITextInput + { + private class AvaloniaTextRange : UITextRange, INSCopying + { + private UITextPosition? _start; + private UITextPosition? _end; + public int StartIndex { get; } + public int EndIndex { get; } + + public AvaloniaTextRange(int startIndex, int endIndex) + { + if (startIndex < 0) + throw new ArgumentOutOfRangeException(nameof(startIndex)); + + if (endIndex < startIndex) + throw new ArgumentOutOfRangeException(nameof(endIndex)); + + StartIndex = startIndex; + EndIndex = endIndex; + } + + public override bool IsEmpty => StartIndex == EndIndex; + + public override UITextPosition Start => _start ??= new AvaloniaTextPosition(StartIndex); + public override UITextPosition End => _end ??= new AvaloniaTextPosition(EndIndex); + public NSObject Copy(NSZone? zone) + { + return new AvaloniaTextRange(StartIndex, EndIndex); + } + } + + private class AvaloniaTextPosition : UITextPosition, INSCopying + { + public AvaloniaTextPosition(int index) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index)); + Index = index; + } + + public int Index { get; } + public NSObject Copy(NSZone? zone) => new AvaloniaTextPosition(Index); + } + + public TextInputResponder(AvaloniaView view, ITextInputMethodClient client) + { + _view = view; + NextResponder = view; + _client = client; + _tokenizer = new UITextInputStringTokenizer(this); + } + + public override UIResponder NextResponder { get; } + + private readonly ITextInputMethodClient _client; + private int _inSurroundingTextUpdateEvent; + private readonly UITextPosition _beginningOfDocument = new AvaloniaTextPosition(0); + private readonly UITextInputStringTokenizer _tokenizer; + + public ITextInputMethodClient? Client => _client; + + public override bool CanResignFirstResponder => true; + + public override bool CanBecomeFirstResponder => true; + + public override UIEditingInteractionConfiguration EditingInteractionConfiguration => + UIEditingInteractionConfiguration.Default; + + public override NSString TextInputContextIdentifier => new NSString(Guid.NewGuid().ToString()); + + public override UITextInputMode TextInputMode => UITextInputMode.CurrentInputMode; + + [DllImport("/usr/lib/libobjc.dylib")] + private static extern void objc_msgSend(IntPtr receiver, IntPtr selector, IntPtr arg); + + private static readonly IntPtr SelectionWillChange = Selector.GetHandle("selectionWillChange:"); + private static readonly IntPtr SelectionDidChange = Selector.GetHandle("selectionDidChange:"); + private static readonly IntPtr TextWillChange = Selector.GetHandle("textWillChange:"); + private static readonly IntPtr TextDidChange = Selector.GetHandle("textDidChange:"); + private readonly AvaloniaView _view; + private string? _markedText; + + + + private void SurroundingTextChanged(object? sender, EventArgs e) + { + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "SurroundingTextChanged"); + if (WeakInputDelegate == null) + return; + _inSurroundingTextUpdateEvent++; + try + { + objc_msgSend(WeakInputDelegate.Handle.Handle, TextWillChange, Handle.Handle); + objc_msgSend(WeakInputDelegate.Handle.Handle, TextDidChange, Handle.Handle); + objc_msgSend(WeakInputDelegate.Handle.Handle, SelectionWillChange, this.Handle.Handle); + objc_msgSend(WeakInputDelegate.Handle.Handle, SelectionDidChange, this.Handle.Handle); + } + finally + { + _inSurroundingTextUpdateEvent--; + } + } + + private void KeyPress(Key ev) + { + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "Triggering key press {key}", ev); + _view._topLevelImpl.Input(new RawKeyEventArgs(KeyboardDevice.Instance!, 0, _view.InputRoot, + RawKeyEventType.KeyDown, ev, RawInputModifiers.None)); + + _view._topLevelImpl.Input(new RawKeyEventArgs(KeyboardDevice.Instance!, 0, _view.InputRoot, + RawKeyEventType.KeyUp, ev, RawInputModifiers.None)); + } + + private void TextInput(string text) + { + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "Triggering text input {text}", text); + _view._topLevelImpl.Input(new RawTextInputEventArgs(KeyboardDevice.Instance!, 0, _view.InputRoot, text)); + } + + void IUIKeyInput.InsertText(string text) + { + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "IUIKeyInput.InsertText {text}", text); + + if (text == "\n") + { + KeyPress(Key.Enter); + return; + } + + TextInput(text); + } + + void IUIKeyInput.DeleteBackward() => KeyPress(Key.Back); + + bool IUIKeyInput.HasText => true; + + string IUITextInput.TextInRange(UITextRange range) + { + var r = (AvaloniaTextRange)range; + var s = _client.SurroundingText; + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "IUIKeyInput.TextInRange {start} {end}", r.StartIndex, r.EndIndex); + + string result = ""; + if(string.IsNullOrEmpty(_markedText)) + result = s.Text[r.StartIndex .. r.EndIndex]; + else + { + var span = new CombinedSpan3(s.Text.AsSpan().Slice(0, s.CursorOffset), + _markedText, + s.Text.AsSpan().Slice(s.CursorOffset)); + var buf = new char[r.EndIndex - r.StartIndex]; + span.CopyTo(buf, r.StartIndex); + result = new string(buf); + } + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "result: {res}", result); + + return result; + } + + void IUITextInput.ReplaceText(UITextRange range, string text) + { + var r = (AvaloniaTextRange)range; + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUIKeyInput.ReplaceText {start} {end} {text}", r.StartIndex, r.EndIndex, text); + _client.SelectInSurroundingText(r.StartIndex, r.EndIndex); + TextInput(text); + } + + void IUITextInput.SetMarkedText(string markedText, NSRange selectedRange) + { + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUIKeyInput.SetMarkedText {start} {len} {text}", selectedRange.Location, + selectedRange.Location, markedText); + + _markedText = markedText; + _client.SetPreeditText(markedText); + } + + void IUITextInput.UnmarkText() + { + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "IUIKeyInput.UnmarkText"); + if(_markedText == null) + return; + var commitString = _markedText; + _markedText = null; + _client.SetPreeditText(null); + if (string.IsNullOrWhiteSpace(commitString)) + return; + TextInput(commitString); + } + + public UITextRange GetTextRange(UITextPosition fromPosition, UITextPosition toPosition) + { + var f = (AvaloniaTextPosition)fromPosition; + var t = (AvaloniaTextPosition)toPosition; + Logger.TryGet(LogEventLevel.Debug, ImeLog)?.Log(null, "IUIKeyInput.GetTextRange {start} {end}", f.Index, t.Index); + + return new AvaloniaTextRange(f.Index, t.Index); + } + + UITextPosition IUITextInput.GetPosition(UITextPosition fromPosition, nint offset) + { + var pos = (AvaloniaTextPosition)fromPosition; + Logger.TryGet(LogEventLevel.Debug, ImeLog) + ?.Log(null, "IUIKeyInput.GetPosition {start} {offset}", pos.Index, (int)offset); + + var res = GetPositionCore(pos, offset); + Logger.TryGet(LogEventLevel.Debug, ImeLog) + ?.Log(null, $"res: " + (res == null ? "null" : (int)res.Index)); + return res!; + } + + private AvaloniaTextPosition? GetPositionCore(AvaloniaTextPosition pos, nint offset) + { + + var end = pos.Index + (int)offset; + if (end < 0) + return null!; + if (end > DocumentLength) + return null; + return new AvaloniaTextPosition(end); + } + + UITextPosition IUITextInput.GetPosition(UITextPosition fromPosition, UITextLayoutDirection inDirection, + nint offset) + { + var pos = (AvaloniaTextPosition)fromPosition; + Logger.TryGet(LogEventLevel.Debug, ImeLog) + ?.Log(null, "IUIKeyInput.GetPosition {start} {direction} {offset}", pos.Index, inDirection, (int)offset); + + var res = GetPositionCore(pos, inDirection, offset); + Logger.TryGet(LogEventLevel.Debug, ImeLog) + ?.Log(null, $"res: " + (res == null ? "null" : (int)res.Index)); + return res!; + } + + private AvaloniaTextPosition? GetPositionCore(AvaloniaTextPosition fromPosition, UITextLayoutDirection inDirection, + nint offset) + { + var f = (AvaloniaTextPosition)fromPosition; + var newPosition = f.Index; + + switch (inDirection) + { + case UITextLayoutDirection.Left: + newPosition -= (int)offset; + break; + + case UITextLayoutDirection.Right: + newPosition += (int)offset; + break; + } + + if (newPosition < 0) + return null!; + + if (newPosition > DocumentLength) + return null!; + + return new AvaloniaTextPosition(newPosition); + } + + NSComparisonResult IUITextInput.ComparePosition(UITextPosition first, UITextPosition second) + { + var f = (AvaloniaTextPosition)first; + var s = (AvaloniaTextPosition)second; + if (f.Index < s.Index) + return NSComparisonResult.Ascending; + + if (f.Index > s.Index) + return NSComparisonResult.Descending; + + return NSComparisonResult.Same; + } + + nint IUITextInput.GetOffsetFromPosition(UITextPosition fromPosition, UITextPosition toPosition) + { + var f = (AvaloniaTextPosition)fromPosition; + var t = (AvaloniaTextPosition)toPosition; + return t.Index - f.Index; + } + + UITextPosition IUITextInput.GetPositionWithinRange(UITextRange range, UITextLayoutDirection direction) + { + var r = (AvaloniaTextRange)range; + + if (direction is UITextLayoutDirection.Right or UITextLayoutDirection.Down) + return r.End; + return r.Start; + } + + UITextRange IUITextInput.GetCharacterRange(UITextPosition byExtendingPosition, UITextLayoutDirection direction) + { + var p = (AvaloniaTextPosition)byExtendingPosition; + if (direction is UITextLayoutDirection.Left or UITextLayoutDirection.Up) + return new AvaloniaTextRange(0, p.Index); + + return new AvaloniaTextRange(p.Index, DocumentLength); + } + + NSWritingDirection IUITextInput.GetBaseWritingDirection(UITextPosition forPosition, + UITextStorageDirection direction) + { + return NSWritingDirection.LeftToRight; + + // todo query and retyrn RTL. + } + + void IUITextInput.SetBaseWritingDirectionforRange(NSWritingDirection writingDirection, UITextRange range) + { + // todo ? ignore? + } + + CGRect IUITextInput.GetFirstRectForRange(UITextRange range) + { + + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUITextInput:GetFirstRectForRange"); + // TODO: Query from the input client + var r = _view._cursorRect; + + return new CGRect(r.Left, r.Top, r.Width, r.Height); + } + + CGRect IUITextInput.GetCaretRectForPosition(UITextPosition? position) + { + // TODO: Query from the input client + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUITextInput:GetCaretRectForPosition"); + var rect = _client.CursorRectangle; + + return new CGRect(rect.X, rect.Y, rect.Width, rect.Height); + } + + UITextPosition IUITextInput.GetClosestPositionToPoint(CGPoint point) + { + // TODO: Query from the input client + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUITextInput:GetClosestPositionToPoint"); + return new AvaloniaTextPosition(0); + } + + UITextPosition IUITextInput.GetClosestPositionToPoint(CGPoint point, UITextRange withinRange) + { + // TODO: Query from the input client + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUITextInput:GetClosestPositionToPoint"); + return new AvaloniaTextPosition(0); + } + + UITextRange IUITextInput.GetCharacterRangeAtPoint(CGPoint point) + { + // TODO: Query from the input client + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUITextInput:GetCharacterRangeAtPoint"); + return new AvaloniaTextRange(0, 0); + } + + UITextSelectionRect[] IUITextInput.GetSelectionRects(UITextRange range) + { + // TODO: Query from the input client + Logger.TryGet(LogEventLevel.Debug, ImeLog)? + .Log(null, "IUITextInput:GetSelectionRect"); + return new UITextSelectionRect[0]; + } + + [Export("textStylingAtPosition:inDirection:")] + public NSDictionary GetTextStylingAtPosition(UITextPosition position, UITextStorageDirection direction) + { + return null!; + } + + UITextRange? IUITextInput.SelectedTextRange + { + get + { + return new AvaloniaTextRange( + Math.Min(_client.SurroundingText.CursorOffset, _client.SurroundingText.AnchorOffset), + Math.Max(_client.SurroundingText.CursorOffset, _client.SurroundingText.AnchorOffset)); + } + set + { + if (_inSurroundingTextUpdateEvent > 0) + return; + if (value == null) + _client.SelectInSurroundingText(_client.SurroundingText.CursorOffset, + _client.SurroundingText.CursorOffset); + else + { + var r = (AvaloniaTextRange)value; + _client.SelectInSurroundingText(r.StartIndex, r.EndIndex); + } + } + } + + NSDictionary? IUITextInput.MarkedTextStyle + { + get => null; + set {} + } + + UITextPosition IUITextInput.BeginningOfDocument => _beginningOfDocument; + + private int DocumentLength => _client.SurroundingText.Text.Length + (_markedText?.Length ?? 0); + UITextPosition IUITextInput.EndOfDocument => new AvaloniaTextPosition(DocumentLength); + + UITextRange IUITextInput.MarkedTextRange + { + get + { + if (string.IsNullOrWhiteSpace(_markedText)) + return null!; + return new AvaloniaTextRange(_client.SurroundingText.CursorOffset, _client.SurroundingText.CursorOffset + _markedText.Length); + } + } + + public override bool BecomeFirstResponder() + { + var res = base.BecomeFirstResponder(); + if (res) + { + Logger.TryGet(LogEventLevel.Debug, "IOSIME") + ?.Log(null, "Became first responder"); + _client.SurroundingTextChanged += SurroundingTextChanged; + CurrentAvaloniaResponder = this; + } + + return res; + } + + + public override bool ResignFirstResponder() + { + var res = base.ResignFirstResponder(); + if (res && ReferenceEquals(CurrentAvaloniaResponder, this)) + { + + Logger.TryGet(LogEventLevel.Debug, "IOSIME") + ?.Log(null, "Resigned first responder"); + _client.SurroundingTextChanged -= SurroundingTextChanged; + CurrentAvaloniaResponder = null; + } + + return res; + } + } +}