From f07db02a28e9d8507490f11eb8f78586b4596969 Mon Sep 17 00:00:00 2001 From: Rastislav Svoboda Date: Sun, 25 May 2025 15:46:54 +0200 Subject: [PATCH] fix TextBox multiline selection with up/down keys till start/end of text (#18746) * fix: TextBox multiline selection with up/down keys till start/end of text * refactor to preserve public API of TextPresenter * refactor and cleanup --- src/Avalonia.Controls/TextBox.cs | 104 ++++++++++-------- .../TextBoxTests.cs | 62 +++++++++++ 2 files changed, 118 insertions(+), 48 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 90d17c432a..7cbf442d88 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -1464,59 +1464,23 @@ namespace Avalonia.Controls break; case Key.Up: + selection = DetectSelection(); + MoveVertical(LogicalDirection.Backward, selection); + if (caretIndex != _presenter.CaretIndex) { - selection = DetectSelection(); - - if (!selection && SelectionStart != SelectionEnd) - { - ClearSelectionAndMoveCaretToTextPosition(LogicalDirection.Backward); - } - - _presenter.MoveCaretVertical(LogicalDirection.Backward); - - if (caretIndex != _presenter.CaretIndex) - { - movement = true; - } - - if (selection) - { - SetCurrentValue(SelectionEndProperty, _presenter.CaretIndex); - } - else - { - SetCurrentValue(CaretIndexProperty, _presenter.CaretIndex); - } - - break; + movement = true; } + break; + case Key.Down: + selection = DetectSelection(); + MoveVertical(LogicalDirection.Forward, selection); + if (caretIndex != _presenter.CaretIndex) { - selection = DetectSelection(); - - if (!selection && SelectionStart != SelectionEnd) - { - ClearSelectionAndMoveCaretToTextPosition(LogicalDirection.Forward); - } - - _presenter.MoveCaretVertical(); - - if (caretIndex != _presenter.CaretIndex) - { - movement = true; - } - - if (selection) - { - SetCurrentValue(SelectionEndProperty, _presenter.CaretIndex); - } - else - { - SetCurrentValue(CaretIndexProperty, _presenter.CaretIndex); - } - - break; + movement = true; } + break; + case Key.Back: { SnapshotUndoRedo(); @@ -2038,6 +2002,50 @@ namespace Avalonia.Controls } } + private void MoveVertical(LogicalDirection direction, bool isSelecting) + { + if (_presenter is null) + { + return; + } + + if (isSelecting) + { + var oldCaretIndex = _presenter.CaretIndex; + _presenter.MoveCaretVertical(direction); + var newCaretIndex = _presenter.CaretIndex; + + if (oldCaretIndex == newCaretIndex) + { + var text = Text ?? string.Empty; + + // caret did not move while we are selecting so we could not move to previous/next line, + // but check if we are already at the 'boundary' of the text + if (direction == LogicalDirection.Forward && newCaretIndex < text.Length) + { + _presenter.MoveCaretToTextPosition(text.Length); + } + else if (direction == LogicalDirection.Backward && newCaretIndex > 0) + { + _presenter.MoveCaretToTextPosition(0); + } + } + + SetCurrentValue(SelectionEndProperty, _presenter.CaretIndex); + } + else + { + if (SelectionStart != SelectionEnd) + { + ClearSelectionAndMoveCaretToTextPosition(direction); + } + + _presenter.MoveCaretVertical(direction); + + SetCurrentValue(CaretIndexProperty, _presenter.CaretIndex); + } + } + private void MoveHome(bool document) { if (_presenter is null) diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 6c1fe6648d..6564eca96f 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -1875,6 +1875,68 @@ namespace Avalonia.Controls.UnitTests } } + [Theory] + [InlineData(0)] + [InlineData(4)] + [InlineData(8)] + public void When_Selecting_Multiline_Selection_Should_Be_Extended_With_Up_Arrow_Key_Till_Start_Of_Text(int caretOffsetFromEnd) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = """ + AAAAAA + BBBB + CCCCCCCC + """, + AcceptsReturn = true + }; + tb.ApplyTemplate(); + tb.Measure(Size.Infinity); + tb.CaretIndex = tb.Text.Length - caretOffsetFromEnd; + + RaiseKeyEvent(tb, Key.Up, KeyModifiers.Shift); + RaiseKeyEvent(tb, Key.Up, KeyModifiers.Shift); + RaiseKeyEvent(tb, Key.Up, KeyModifiers.Shift); + RaiseKeyEvent(tb, Key.Up, KeyModifiers.Shift); + + Assert.Equal(0, tb.SelectionEnd); + } + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(6)] + public void When_Selecting_Multiline_Selection_Should_Be_Extended_With_Down_Arrow_Key_Till_End_Of_Text(int caretOffsetFromStart) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = """ + AAAAAA + BBBB + CCCCCCCC + """, + AcceptsReturn = true + }; + tb.ApplyTemplate(); + tb.Measure(Size.Infinity); + tb.CaretIndex = caretOffsetFromStart; + + RaiseKeyEvent(tb, Key.Down, KeyModifiers.Shift); + RaiseKeyEvent(tb, Key.Down, KeyModifiers.Shift); + RaiseKeyEvent(tb, Key.Down, KeyModifiers.Shift); + RaiseKeyEvent(tb, Key.Down, KeyModifiers.Shift); + + Assert.Equal(tb.Text.Length, tb.SelectionEnd); + } + } + [Fact] public void TextBox_In_AdornerLayer_Will_Not_Cause_Collection_Modified_In_VisualLayerManager_Measure() {