Răsfoiți Sursa

X11 IME preedit, preedit cursor, input context improvements (#13282)

Co-authored-by: Dan Walmsley <[email protected]>
Nikita Tsukanov 2 ani în urmă
părinte
comite
5b02b0389a

+ 24 - 2
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()
         {
 

+ 49 - 14
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 - 0
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 - 0
src/Avalonia.Base/Input/TextInput/TextInputMethodClientRequeryRequestedEventArgs.cs

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

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

+ 24 - 4
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)

+ 4 - 1
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)

+ 15 - 1
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 - 0
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;
     }

+ 79 - 25
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)

+ 53 - 1
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);
+        }
     }
 }

+ 1 - 1
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 use Input Focus Proxy.

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