From 03b49353d04d1fe6b8355ac57f774579a46b97ae Mon Sep 17 00:00:00 2001 From: Zeljko Predjeskovic Date: Wed, 1 Jul 2026 12:48:51 +0200 Subject: [PATCH] #13435 - Android - Image annotation UX: text size not persisted, color picker OK button on wrong side #13429 - Svg editor color picker does not select correctly on cancelling or not selecting a color --- .../Dialog/IUserInteraction.cs | 13 + .../Dialog/UserInteractionService.cs | 75 ++++- .../Views/ActionSheetDialogViewModel.cs | 2 +- .../Views/ColorPickerDialogViewModel.cs | 12 +- .../Views/ContentDialogResultViewModelBase.cs | 2 +- .../Dialog/Views/InputDialogViewModel.cs | 2 +- .../Views/TextOptionsDialogContent.axaml | 19 ++ .../Views/TextOptionsDialogContent.axaml.cs | 26 ++ .../Views/TextOptionsDialogResultViewModel.cs | 47 +++ .../Services/IColorPickerState.cs | 18 + .../Services/TextInputService.cs | 45 ++- .../Svg.Editor.Avalon.Forms.csproj | 4 +- Svg.Editor.Avalonia.Forms/SvgEditorForms.cs | 1 + .../Svg.Editor.Avalon.Views.csproj | 4 +- .../TextOptionsDialogResultViewModelTests.cs | 99 ++++++ .../TextInputServiceTests.cs | 318 ++++++++++++++++++ Svg.Editor.Core/Svg.Editor.Core.csproj | 4 +- Svg/Svg.csproj | 4 +- 18 files changed, 647 insertions(+), 48 deletions(-) create mode 100644 Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml create mode 100644 Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml.cs create mode 100644 Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogResultViewModel.cs create mode 100644 Svg.Editor.Avalonia.Forms/Services/IColorPickerState.cs create mode 100644 Svg.Editor.Core.Tests/Forms/TextOptionsDialogResultViewModelTests.cs create mode 100644 Svg.Editor.Core.Tests/TextInputServiceTests.cs diff --git a/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs b/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs index 316c1b39b..315143830 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs @@ -6,6 +6,8 @@ namespace Svg.Editor.Avalon.Forms.Dialog; +public record InputWithOptionsResponse(bool Ok, string? Text, string? SelectedOption, int SelectedIndex); + public interface IUserInteraction { Task ConfirmAsync(string message, string? title = null, string okButton = "OK", string cancelButton = "Cancel", bool cancellable = false); @@ -41,6 +43,17 @@ Task ConfirmThreeButtonsAsync( Task PromptTimeAsync(TimeSpan? selectedTime); */ + + Task InputWithOptionsAsync( + string message, + IEnumerable options, + string? title = null, + string okButton = "OK", + string cancelButton = "Cancel", + string? initialText = null, + string? placeholder = null, + int selectedIndex = 0, + bool cancellable = true); void Dismiss(); } \ No newline at end of file diff --git a/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs b/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs index 5cc338901..3de8f1b38 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs @@ -1,19 +1,28 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Labs.Controls; using Avalonia.Media; using Avalonia.Threading; using Svg.Editor.Avalon.Forms.Dialog.Views; +using Svg.Editor.Avalon.Forms.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace Svg.Editor.Avalon.Forms.Dialog; public class UserInteractionService : IUserInteraction { + private readonly IColorPickerState _colorPickerState; + + public UserInteractionService() + { + _colorPickerState = SvgEngine.Resolve(); + } + public async Task ConfirmAsync(string message, string? title = null, string okButton = "OK", string cancelButton = "Cancel", bool cancellable = false) { return await Dispatcher.UIThread.InvokeAsync(async () => @@ -161,7 +170,10 @@ public Task ColorPickerAsync(string? title = null, string okButton = "OK" PrimaryButtonText = okButton, CloseButtonText = cancellable ? cancelButton : null, }; - var vm = new ColorPickerDialogViewModel(); + var vm = new ColorPickerDialogViewModel() + { + SelectedColor = _colorPickerState.LastPickedColor, + }; vm.Initialize(d); @@ -169,9 +181,12 @@ public Task ColorPickerAsync(string? title = null, string okButton = "OK" var r = await d.ShowAsync(); - if (r == ContentDialogResult.Primary) - return vm.GetResult(); - return Colors.Black; + if (r == ContentDialogResult.Primary){ + var result = vm.GetResult(); + _colorPickerState.LastPickedColor = result; + return result; + } + return _colorPickerState.LastPickedColor; }); } @@ -223,6 +238,48 @@ public void Dismiss() }); } + public Task InputWithOptionsAsync(string message, IEnumerable options, string? title = null, string okButton = "OK", string cancelButton = "Cancel", string? initialText = null, string? placeholder = null, int selectedIndex = 0, bool cancellable = true) + { + return Dispatcher.UIThread.InvokeAsync(async () => + { + var optionsList = options?.ToList() ?? new List(); + + var d = new ContentDialog() + { + Title = title, + Content = message, + IsSecondaryButtonEnabled = false, + PrimaryButtonText = okButton, + CloseButtonText = cancellable ? cancelButton : null, + }; + + var vm = new TextOptionsDialogResultViewModel + { + UserInput = initialText, + WatermarkText = placeholder, + Options = optionsList, + SelectedIndex = optionsList.Count > 0 + ? Math.Clamp(selectedIndex, 0, optionsList.Count - 1) + : -1, + CanCancel = cancellable + }; + vm.Initialize(d); + + d.Content = new TextOptionsDialogContent { DataContext = vm }; + d.AttachKeyboardControl(cancellable); + + var r = await d.ShowAsync(); + + if (r == ContentDialogResult.Primary) + { + var result = vm.GetResult(); + return new InputWithOptionsResponse(true, result?.Text, result?.SelectedOption, result?.SelectedIndex ?? -1); + } + + return new InputWithOptionsResponse(false, null, null, -1); + }); + } + #endregion diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs b/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs index 61314fe05..e201ca518 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs @@ -39,7 +39,7 @@ protected override bool CanClose(ContentDialogResult result) return base.CanClose(result); } - internal override object? GetResult() + public override object? GetResult() { return SelectedItem?.Value; } diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/ColorPickerDialogViewModel.cs b/Svg.Editor.Avalonia.Forms/Dialog/Views/ColorPickerDialogViewModel.cs index 3cfef8699..0d7cb2549 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/Views/ColorPickerDialogViewModel.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/ColorPickerDialogViewModel.cs @@ -8,21 +8,15 @@ public class ColorPickerDialogViewModel : ContentDialogResultViewModelBase _color; + set => _color = value; } public ColorPickerDialogViewModel() { } - internal override Color GetResult() + public override Color GetResult() { return SelectedColor; } diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/ContentDialogResultViewModelBase.cs b/Svg.Editor.Avalonia.Forms/Dialog/Views/ContentDialogResultViewModelBase.cs index cfbf4f154..decfc1434 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/Views/ContentDialogResultViewModelBase.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/ContentDialogResultViewModelBase.cs @@ -2,5 +2,5 @@ namespace Svg.Editor.Avalon.Forms.Dialog.Views; public abstract class ContentDialogResultViewModelBase : ContentDialogViewModelBase { - internal abstract TResult GetResult(); + public abstract TResult GetResult(); } \ No newline at end of file diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/InputDialogViewModel.cs b/Svg.Editor.Avalonia.Forms/Dialog/Views/InputDialogViewModel.cs index dac5211b9..6fa45b543 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/Views/InputDialogViewModel.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/InputDialogViewModel.cs @@ -16,7 +16,7 @@ public string? WatermarkText } - internal override string? GetResult() + public override string? GetResult() { return UserInput; } diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml b/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml new file mode 100644 index 000000000..ca2dd60a6 --- /dev/null +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml @@ -0,0 +1,19 @@ + + + + + + diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml.cs b/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml.cs new file mode 100644 index 000000000..7b4939965 --- /dev/null +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogContent.axaml.cs @@ -0,0 +1,26 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace Svg.Editor.Avalon.Forms; + +public partial class TextOptionsDialogContent : UserControl +{ + public TextOptionsDialogContent() + { + InitializeComponent(); + } + + private void InputField_OnAttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs e) + { + if (sender is InputElement inputElement) + { + Dispatcher.UIThread.Post(() => + { + inputElement.Focus(NavigationMethod.Unspecified, KeyModifiers.None); + }); + } + } +} \ No newline at end of file diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogResultViewModel.cs b/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogResultViewModel.cs new file mode 100644 index 000000000..753f8d0f0 --- /dev/null +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/TextOptionsDialogResultViewModel.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Svg.Editor.Avalon.Forms.Dialog.Views; + +public record TextOptionsResult(string? Text, string? SelectedOption, int SelectedIndex); + +public class TextOptionsDialogResultViewModel : ContentDialogResultViewModelBase +{ + private string? _userInput; + public string? UserInput + { + get => _userInput; + set => SetField(ref _userInput, value); + } + + private string? _watermarkText; + public string? WatermarkText + { + get => _watermarkText; + set => SetField(ref _watermarkText, value); + } + + private IReadOnlyList _options = Array.Empty(); + public IReadOnlyList Options + { + get => _options; + set => SetField(ref _options, value); + } + + private int _selectedIndex; + public int SelectedIndex + { + get => _selectedIndex; + set => SetField(ref _selectedIndex, value); + } + + public override TextOptionsResult? GetResult() + { + var selected = SelectedIndex >= 0 && SelectedIndex < Options.Count + ? Options[SelectedIndex] + : null; + + return new TextOptionsResult(UserInput, selected, SelectedIndex); + } +} diff --git a/Svg.Editor.Avalonia.Forms/Services/IColorPickerState.cs b/Svg.Editor.Avalonia.Forms/Services/IColorPickerState.cs new file mode 100644 index 000000000..cb98a7f6b --- /dev/null +++ b/Svg.Editor.Avalonia.Forms/Services/IColorPickerState.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Avalonia.Media; + +namespace Svg.Editor.Avalon.Forms.Services +{ + public interface IColorPickerState + { + Color LastPickedColor { get; set; } + } + + public class ColorPickerState : IColorPickerState + { + public Color LastPickedColor { get; set; } = Colors.Black; + } + +} diff --git a/Svg.Editor.Avalonia.Forms/Services/TextInputService.cs b/Svg.Editor.Avalonia.Forms/Services/TextInputService.cs index 62770a055..9d2ef787a 100644 --- a/Svg.Editor.Avalonia.Forms/Services/TextInputService.cs +++ b/Svg.Editor.Avalonia.Forms/Services/TextInputService.cs @@ -18,35 +18,34 @@ public TextInputService() public async Task GetUserInput(string title, string textValue = "", IEnumerable textSizeOptions = null, int textSizeSelected = 0, int maxTextLength = -1) { - var result = await _userInteractionService.InputAsync("Text edit", title, "Ok", "Cancel", textValue, placeholder: "Enter text"); - if (maxTextLength != -1) - { - while (result.Text.Length > 2) - { - result = await _userInteractionService.InputAsync("Text edit", title, "Ok", "Cancel", textValue, placeholder: "Enter text"); - } - } - var defaultResult = new TextTool.TextProperties - { - FontSizeIndex = textSizeSelected, - LineHeight = 12f, - Text = textValue - }; - var text = result.Text; + var sizeOptions = textSizeOptions?.ToList() ?? new List(); + + var result = await _userInteractionService.InputWithOptionsAsync( + "Text edit", + sizeOptions, + title, + "Ok", + "Cancel", + textValue, + placeholder: "Enter text", + selectedIndex: textSizeSelected); + if (!result.Ok) { - return defaultResult; + return new TextTool.TextProperties + { + FontSizeIndex = textSizeSelected, + LineHeight = 12f, + Text = textValue + }; } - int sizeIndex = textSizeSelected; - if (textSizeOptions != null) - { - var sizeResult = await _userInteractionService.ActionSheetAsync("Font size", textSizeOptions.ToArray()); + var text = result.Text ?? string.Empty; + if (maxTextLength != -1 && text.Length > maxTextLength) + text = text.Substring(0, maxTextLength); - sizeIndex = textSizeOptions.ToList().IndexOf(sizeResult); - sizeIndex = sizeIndex >= 0 ? sizeIndex : textSizeSelected; - } + var sizeIndex = result.SelectedIndex >= 0 ? result.SelectedIndex : textSizeSelected; return new TextTool.TextProperties { diff --git a/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj b/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj index 52e36113d..c907d0c55 100644 --- a/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj +++ b/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj @@ -3,8 +3,10 @@ net10.0 enable latest - 3.2.0-optiq04 + 3.2.0-optiq05 + #3.2.0-optiq05 + Improved TextInputService and Colorpicker #3.2.0-optiq04 Improved marker options input service and StrokeStyleOptionsInputService #3.2.0-optiq01 diff --git a/Svg.Editor.Avalonia.Forms/SvgEditorForms.cs b/Svg.Editor.Avalonia.Forms/SvgEditorForms.cs index a727235cf..3907a62ae 100644 --- a/Svg.Editor.Avalonia.Forms/SvgEditorForms.cs +++ b/Svg.Editor.Avalonia.Forms/SvgEditorForms.cs @@ -31,6 +31,7 @@ public static void Init(Avalonia.Application app) SvgEngine.Register(() => new FormsPickImageService()); SvgEngine.Register(() => new PinInputService()); SvgEngine.Register(() => new ToolTipInfoService()); + SvgEngine.RegisterSingleton(() => new ColorPickerState()); SvgEngine.Register(() => new UserInteractionService()); app.DataTemplates.Add(new MenuItemHeaderTemplate()); diff --git a/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj b/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj index 4c71c40bf..c6fbd6ce2 100644 --- a/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj +++ b/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj @@ -3,8 +3,10 @@ net10.0 enable latest - 3.2.0-optiq04 + 3.2.0-optiq05 + #3.2.0-optiq05 + Improved TextInputService and Colorpicker #3.2.0-optiq04 Improved marker options input service and StrokeStyleOptionsInputService #3.2.0-optiq01 diff --git a/Svg.Editor.Core.Tests/Forms/TextOptionsDialogResultViewModelTests.cs b/Svg.Editor.Core.Tests/Forms/TextOptionsDialogResultViewModelTests.cs new file mode 100644 index 000000000..77ccf9d03 --- /dev/null +++ b/Svg.Editor.Core.Tests/Forms/TextOptionsDialogResultViewModelTests.cs @@ -0,0 +1,99 @@ +using NUnit.Framework; +using Shouldly; +using Svg.Editor.Avalon.Forms.Dialog.Views; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Svg.Editor.Core.Tests.Forms +{ + [TestFixture] + public class TextOptionsDialogResultViewModelTests + { + private TextOptionsDialogResultViewModel _vm = null!; + + [SetUp] + public void SetUp() + { + _vm = new TextOptionsDialogResultViewModel(); + } + + [Test] + public void GetResult_WithValidSelectedIndex_ReturnsMatchingOptionAndIndex() + { + _vm.UserInput = "user text"; + _vm.Options = new List { "Alpha", "Beta", "Gamma" }; + _vm.SelectedIndex = 1; + + var result = _vm.GetResult(); + + result.ShouldNotBeNull(); + result!.Text.ShouldBe("user text"); + result.SelectedOption.ShouldBe("Beta"); + result.SelectedIndex.ShouldBe(1); + } + + [Test] + public void GetResult_WithNegativeSelectedIndex_ReturnsNullSelectedOption() + { + _vm.Options = new List { "Alpha", "Beta" }; + _vm.SelectedIndex = -1; + + var result = _vm.GetResult(); + + result.ShouldNotBeNull(); + result!.SelectedOption.ShouldBeNull(); + result.SelectedIndex.ShouldBe(-1); + } + + [Test] + public void GetResult_WithSelectedIndexGreaterThanOrEqualToCount_ReturnsNullSelectedOption() + { + _vm.Options = new List { "Alpha", "Beta" }; + _vm.SelectedIndex = 2; + + var result = _vm.GetResult(); + + result.ShouldNotBeNull(); + result!.SelectedOption.ShouldBeNull(); + result.SelectedIndex.ShouldBe(2); + } + + [Test] + public void GetResult_WithEmptyOptions_ReturnsNullSelectedOption() + { + _vm.Options = Array.Empty(); + _vm.SelectedIndex = 0; + + var result = _vm.GetResult(); + + result.ShouldNotBeNull(); + result!.SelectedOption.ShouldBeNull(); + } + + [Test] + public void GetResult_WithNullUserInput_ReturnsNullText() + { + _vm.UserInput = null; + _vm.Options = new List { "Alpha" }; + _vm.SelectedIndex = 0; + + var result = _vm.GetResult(); + + result.ShouldNotBeNull(); + result!.Text.ShouldBeNull(); + result.SelectedOption.ShouldBe("Alpha"); + } + + [Test] + public void GetResult_DefaultState_ReturnsNullTextAndNullSelectedOptionWithZeroIndex() + { + var result = _vm.GetResult(); + + result.ShouldNotBeNull(); + result!.Text.ShouldBeNull(); + result.SelectedOption.ShouldBeNull(); + result.SelectedIndex.ShouldBe(0); + } + } +} diff --git a/Svg.Editor.Core.Tests/TextInputServiceTests.cs b/Svg.Editor.Core.Tests/TextInputServiceTests.cs new file mode 100644 index 000000000..f1a71b10d --- /dev/null +++ b/Svg.Editor.Core.Tests/TextInputServiceTests.cs @@ -0,0 +1,318 @@ +using Moq; +using NUnit.Framework; +using Shouldly; +using Svg.Editor.Avalon.Forms.Dialog; +using Svg.Editor.Avalon.Forms.Services; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.Linq; + +namespace Svg.Editor.Core.Tests +{ + [TestFixture] + public class TextInputServiceTests + { + private Mock _userInteractionMock; + private TextInputService _service; + + [SetUp] + public void Setup() + { + SvgEditor.Init(); + _userInteractionMock = new Mock(); + SvgEngine.Register(() => _userInteractionMock.Object); + + _service = new TextInputService(); + } + + [Test] + public async Task GetUserInput_WithConfirmedResult_ReturnsEnteredTextAndSelectedIndex() + { + // Arrange + var sizeOptions = new[] { "Small", "Medium", "Large" }; + + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, "Hello world", "Large", 2)); + + // Act + var result = await _service.GetUserInput("My Title", "initial", sizeOptions, textSizeSelected: 0); + + // Assert + result.ShouldNotBeNull(); + result.Text.ShouldBe("Hello world"); + result.FontSizeIndex.ShouldBe(2); + result.LineHeight.ShouldBe(12f); + } + + [Test] + public async Task GetUserInput_WithCancelledResult_ReturnsOriginalTextAndSelectedIndex() + { + // Arrange + var sizeOptions = new[] { "Small", "Medium", "Large" }; + const string originalText = "initial text"; + const int originalIndex = 1; + + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(false, null, null, -1)); + + // Act + var result = await _service.GetUserInput("My Title", originalText, sizeOptions, originalIndex); + + // Assert + result.ShouldNotBeNull(); + result.Text.ShouldBe(originalText); + result.FontSizeIndex.ShouldBe(originalIndex); + result.LineHeight.ShouldBe(12f); + } + + [Test] + public async Task GetUserInput_WithTextExceedingMaxLength_TruncatesText() + { + // Arrange + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, "This is way too long", null, -1)); + + // Act + var result = await _service.GetUserInput("Title", "initial", null, 0, maxTextLength: 5); + + // Assert + result.Text.ShouldBe("This "); + result.Text.Length.ShouldBe(5); + } + + [Test] + public async Task GetUserInput_WithMaxTextLengthDisabled_DoesNotTruncateText() + { + // Arrange + const string longText = "This is a fairly long piece of text"; + + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, longText, null, -1)); + + // Act + var result = await _service.GetUserInput("Title", "initial", null, 0, maxTextLength: -1); + + // Assert + result.Text.ShouldBe(longText); + } + + [Test] + public async Task GetUserInput_WithTextShorterThanMaxLength_DoesNotTruncateText() + { + // Arrange + const string shortText = "Hi"; + + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, shortText, null, -1)); + + // Act + var result = await _service.GetUserInput("Title", "initial", null, 0, maxTextLength: 10); + + // Assert + result.Text.ShouldBe(shortText); + } + + [Test] + public async Task GetUserInput_WithNegativeSelectedIndex_FallsBackToTextSizeSelected() + { + // Arrange + const int fallbackIndex = 3; + + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, "some text", null, -1)); + + // Act + var result = await _service.GetUserInput("Title", "initial", new[] { "A", "B" }, fallbackIndex); + + // Assert + result.FontSizeIndex.ShouldBe(fallbackIndex); + } + + [Test] + public async Task GetUserInput_WithNullResultText_ReturnsEmptyString() + { + // Arrange + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, null, null, 0)); + + // Act + var result = await _service.GetUserInput("Title", "initial", null, 0); + + // Assert + result.Text.ShouldBe(string.Empty); + } + + [Test] + public async Task GetUserInput_WithNullTextSizeOptions_PassesEmptyListToInteractionService() + { + // Arrange + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, "text", null, -1)); + + // Act + await _service.GetUserInput("Title", "initial", null, 0); + + // Assert + _userInteractionMock.Verify( + x => x.InputWithOptionsAsync( + It.IsAny(), + It.Is>(o => !o.Any()), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task GetUserInput_CallsInputWithOptionsAsync_WithCorrectParameters() + { + // Arrange + var sizeOptions = new[] { "Small", "Medium", "Large" }; + const string title = "Edit Text"; + const string initialText = "hello"; + const int selected = 1; + + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(true, initialText, "Medium", selected)); + + // Act + await _service.GetUserInput(title, initialText, sizeOptions, selected); + + // Assert + _userInteractionMock.Verify( + x => x.InputWithOptionsAsync( + "Text edit", + It.Is>(o => o.SequenceEqual(sizeOptions)), + title, + "Ok", + "Cancel", + initialText, + "Enter text", + selected, + It.IsAny()), + Times.Once); + } + + [Test] + public async Task GetUserInput_WhenCancelled_DoesNotTruncateOriginalTextValue() + { + // Arrange + const string originalText = "This is longer than the max length"; + + _userInteractionMock + .Setup(x => x.InputWithOptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new InputWithOptionsResponse(false, null, null, -1)); + + // Act + var result = await _service.GetUserInput("Title", originalText, null, 0, maxTextLength: 5); + + // Assert - cancellation path returns textValue untouched, even beyond maxTextLength + result.Text.ShouldBe(originalText); + } + } +} diff --git a/Svg.Editor.Core/Svg.Editor.Core.csproj b/Svg.Editor.Core/Svg.Editor.Core.csproj index d3ca5491e..ff030f840 100644 --- a/Svg.Editor.Core/Svg.Editor.Core.csproj +++ b/Svg.Editor.Core/Svg.Editor.Core.csproj @@ -4,9 +4,11 @@ Svg.Editor en-US net10.0 - 3.2.0-optiq04 + 3.2.0-optiq05 latest + #3.2.0-optiq05 + Improved TextInputService and Colorpicker #3.2.0-optiq04 Improved marker options input service and StrokeStyleOptionsInputService #3.2.0-optiq01 diff --git a/Svg/Svg.csproj b/Svg/Svg.csproj index 9a95edde0..de6ee5698 100644 --- a/Svg/Svg.csproj +++ b/Svg/Svg.csproj @@ -3,10 +3,12 @@ netstandard2.0;net48;net10.0 PackageReference - 3.2.0-optiq04 + 3.2.0-optiq05 gentledpp,zepr Opti-Q GmbH + #3.2.0-optiq05 + Improved TextInputService and Colorpicker #3.2.0-optiq04 Improved marker options input service and StrokeStyleOptionsInputService #3.2.0-optiq03