Fixes #4112. WordForward and WordBackward are not consistent with identical RuneType (#4131)

* Move parallelizable to new file

* Add UseSameRuneTypeForWords property

* Add SelectWordOnlyOnDoubleClick property and ProcessDoubleClickSelection method

* Change IsSameRuneType method to also handle equivalent rune types

* Fix WordBackward and WordForward to support properly handle rune types

* Fix unit test to deal properly with the new roles of rune types

* Add new unit tests

* Remove duplicated unit test

* Add UseSameRuneTypeForWords and SelectWordOnlyOnDoubleClick handling into Editor scenario
This commit is contained in:
BDisp
2025-06-09 21:29:45 +01:00
committed by GitHub
parent b59ac3b69c
commit e1086a45a9
8 changed files with 2777 additions and 2239 deletions

View File

@@ -570,6 +570,19 @@ public class TextField : View, IDesignable
/// </summary>
public bool Used { get; set; }
/// <summary>
/// Gets or sets whether the word forward and word backward navigation should use the same or equivalent rune type.
/// Default is <c>false</c> meaning using equivalent rune type.
/// </summary>
public bool UseSameRuneTypeForWords { get; set; }
/// <summary>
/// Gets or sets whether the word navigation should select only the word itself without spaces around it or with the
/// spaces at right.
/// Default is <c>false</c> meaning that the spaces at right are included in the selection.
/// </summary>
public bool SelectWordOnlyOnDoubleClick { get; set; }
/// <summary>Clear the selected text.</summary>
public void ClearAllSelection ()
{
@@ -754,7 +767,7 @@ public class TextField : View, IDesignable
public virtual void KillWordBackwards ()
{
ClearAllSelection ();
(int col, int row)? newPos = GetModel ().WordBackward (_cursorPosition, 0);
(int col, int row)? newPos = GetModel ().WordBackward (_cursorPosition, 0, UseSameRuneTypeForWords);
if (newPos is null)
{
@@ -777,7 +790,7 @@ public class TextField : View, IDesignable
public virtual void KillWordForwards ()
{
ClearAllSelection ();
(int col, int row)? newPos = GetModel ().WordForward (_cursorPosition, 0);
(int col, int row)? newPos = GetModel ().WordForward (_cursorPosition, 0, UseSameRuneTypeForWords);
if (newPos is null)
{
@@ -857,43 +870,15 @@ public class TextField : View, IDesignable
{
EnsureHasFocus ();
int x = PositionCursor (ev);
int sbw = x;
(int startCol, int col, int row)? newPos = GetModel ().ProcessDoubleClickSelection (x, x, 0, UseSameRuneTypeForWords, SelectWordOnlyOnDoubleClick);
if (x == _text.Count
|| (x > 0 && (char)_text [x - 1].Value != ' ')
|| (x > 0 && (char)_text [x].Value == ' '))
{
(int col, int row)? newPosBw = GetModel ().WordBackward (x, 0);
if (newPosBw is null)
{
return true;
}
sbw = newPosBw.Value.col;
}
if (sbw != -1)
{
x = sbw;
PositionCursor (x);
}
(int col, int row)? newPosFw = GetModel ().WordForward (x, 0);
if (newPosFw is null)
if (newPos is null)
{
return true;
}
ClearAllSelection ();
if (newPosFw.Value.col != -1 && sbw != -1)
{
_cursorPosition = newPosFw.Value.col;
}
PrepareSelection (sbw, newPosFw.Value.col - sbw);
SelectedStart = newPos.Value.startCol;
CursorPosition = newPos.Value.col;
}
else if (ev.Flags == MouseFlags.Button1TripleClicked)
{
@@ -1502,7 +1487,7 @@ public class TextField : View, IDesignable
private void MoveWordLeft ()
{
ClearAllSelection ();
(int col, int row)? newPos = GetModel ().WordBackward (_cursorPosition, 0);
(int col, int row)? newPos = GetModel ().WordBackward (_cursorPosition, 0, UseSameRuneTypeForWords);
if (newPos is null)
{
@@ -1528,7 +1513,7 @@ public class TextField : View, IDesignable
if (x > 0)
{
(int col, int row)? newPos = GetModel ().WordBackward (x, 0);
(int col, int row)? newPos = GetModel ().WordBackward (x, 0, UseSameRuneTypeForWords);
if (newPos is null)
{
@@ -1548,7 +1533,7 @@ public class TextField : View, IDesignable
private void MoveWordRight ()
{
ClearAllSelection ();
(int col, int row)? newPos = GetModel ().WordForward (_cursorPosition, 0);
(int col, int row)? newPos = GetModel ().WordForward (_cursorPosition, 0, UseSameRuneTypeForWords);
if (newPos is null)
{
@@ -1568,7 +1553,7 @@ public class TextField : View, IDesignable
if (_cursorPosition < _text.Count)
{
int x = _start > -1 && _start > _cursorPosition ? _start : _cursorPosition;
(int col, int row)? newPos = GetModel ().WordForward (x, 0);
(int col, int row)? newPos = GetModel ().WordForward (x, 0, UseSameRuneTypeForWords);
if (newPos is null)
{

View File

@@ -206,7 +206,7 @@ internal class TextModel
return sb.ToString ();
}
public (int col, int row)? WordBackward (int fromCol, int fromRow)
public (int col, int row)? WordBackward (int fromCol, int fromRow, bool useSameRuneType)
{
if (fromRow == 0 && fromCol == 0)
{
@@ -245,7 +245,7 @@ internal class TextModel
RuneType runeType = GetRuneType (rune);
int lastValidCol = IsSameRuneType (rune, runeType) && (Rune.IsLetterOrDigit (rune) || Rune.IsPunctuation (rune) || Rune.IsSymbol (rune))
int lastValidCol = IsSameRuneType (rune, runeType, useSameRuneType) && (Rune.IsLetterOrDigit (rune) || Rune.IsPunctuation (rune) || Rune.IsSymbol (rune))
? col
: -1;
@@ -253,21 +253,29 @@ internal class TextModel
{
if (Rune.IsWhiteSpace (nRune))
{
while (MovePrev (ref nCol, ref nRow, out nRune))
while (MovePrev (ref nCol, ref nRow, out nRune, useSameRuneType))
{
lastValidCol = nCol;
if (Rune.IsLetterOrDigit (nRune) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune))
{
lastValidCol = nCol;
if (runeType == RuneType.IsWhiteSpace || runeType == RuneType.IsUnknown)
{
runeType = GetRuneType (nRune);
}
break;
rune = nRune;
runeType = GetRuneType (nRune);
}
}
if (lastValidCol > -1)
{
nCol = lastValidCol;
nRow = fromRow;
}
if ((!Rune.IsWhiteSpace (nRune) && Rune.IsWhiteSpace (rune))
|| (Rune.IsWhiteSpace (nRune) && !Rune.IsWhiteSpace (rune)))
{
return;
}
if (nRow != fromRow && (Rune.IsLetterOrDigit (nRune) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune)))
{
List<Cell> line = GetLine (nRow);
@@ -276,38 +284,18 @@ internal class TextModel
{
nCol = lastValidCol + Math.Max (lastValidCol, line.Count);
}
return;
}
while (MovePrev (ref nCol, ref nRow, out nRune))
{
if (!Rune.IsLetterOrDigit (nRune) && !Rune.IsPunctuation (nRune) && !Rune.IsSymbol (nRune))
{
break;
}
if (nRow != fromRow)
{
break;
}
lastValidCol =
(IsSameRuneType (nRune, runeType) && Rune.IsLetterOrDigit (nRune)) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune)
? nCol
: lastValidCol;
}
if (lastValidCol > -1)
{
nCol = lastValidCol;
nRow = fromRow;
}
}
else
{
if (!MovePrev (ref nCol, ref nRow, out nRune))
if (!MovePrev (ref nCol, ref nRow, out nRune, useSameRuneType))
{
if (lastValidCol > -1)
{
nCol = lastValidCol;
nRow = fromRow;
}
return;
}
@@ -321,7 +309,7 @@ internal class TextModel
}
lastValidCol =
(IsSameRuneType (nRune, runeType) && Rune.IsLetterOrDigit (nRune)) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune)
(IsSameRuneType (nRune, runeType, useSameRuneType) && Rune.IsLetterOrDigit (nRune)) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune)
? nCol
: lastValidCol;
@@ -350,6 +338,15 @@ internal class TextModel
return (col, row);
}
if (fromCol == col && fromRow == row && row > 0)
{
row--;
List<Cell> line = GetLine (row);
col = line.Count;
return (col, row);
}
return null;
}
catch (Exception)
@@ -358,7 +355,7 @@ internal class TextModel
}
}
public (int col, int row)? WordForward (int fromCol, int fromRow)
public (int col, int row)? WordForward (int fromCol, int fromRow, bool useSameRuneType)
{
if (fromRow == _lines.Count - 1 && fromCol == GetLine (_lines.Count - 1).Count)
{
@@ -370,10 +367,10 @@ internal class TextModel
try
{
Rune rune = RuneAt (col, row)!.Value.Rune;
Rune rune = _lines [row].Count > 0 ? RuneAt (col, row)!.Value.Rune : default (Rune);
RuneType runeType = GetRuneType (rune);
int lastValidCol = IsSameRuneType (rune, runeType) && (Rune.IsLetterOrDigit (rune) || Rune.IsPunctuation (rune) || Rune.IsSymbol (rune))
int lastValidCol = IsSameRuneType (rune, runeType, useSameRuneType) && (Rune.IsLetterOrDigit (rune) || Rune.IsPunctuation (rune) || Rune.IsSymbol (rune))
? col
: -1;
@@ -381,16 +378,23 @@ internal class TextModel
{
if (Rune.IsWhiteSpace (nRune))
{
while (MoveNext (ref nCol, ref nRow, out nRune))
while (MoveNext (ref nCol, ref nRow, out nRune, useSameRuneType))
{
lastValidCol = nCol;
if (Rune.IsLetterOrDigit (nRune) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune))
{
lastValidCol = nCol;
return;
}
}
lastValidCol = nCol;
if (!Rune.IsWhiteSpace (nRune) && Rune.IsWhiteSpace (rune))
{
return;
}
if (nRow != fromRow && (Rune.IsLetterOrDigit (nRune) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune)))
{
if (lastValidCol > -1)
@@ -401,24 +405,6 @@ internal class TextModel
return;
}
while (MoveNext (ref nCol, ref nRow, out nRune))
{
if (!Rune.IsLetterOrDigit (nRune) && !Rune.IsPunctuation (nRune) && !Rune.IsSymbol (nRune))
{
break;
}
if (nRow != fromRow)
{
break;
}
lastValidCol =
(IsSameRuneType (nRune, runeType) && Rune.IsLetterOrDigit (nRune)) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune)
? nCol
: lastValidCol;
}
if (lastValidCol > -1)
{
nCol = lastValidCol;
@@ -427,12 +413,14 @@ internal class TextModel
}
else
{
if (!MoveNext (ref nCol, ref nRow, out nRune))
if (!MoveNext (ref nCol, ref nRow, out nRune, useSameRuneType))
{
return;
}
if (!IsSameRuneType (nRune, runeType) && !Rune.IsWhiteSpace (nRune))
lastValidCol = nCol;
if (!IsSameRuneType (nRune, runeType, useSameRuneType) && !Rune.IsWhiteSpace (nRune))
{
return;
}
@@ -446,11 +434,6 @@ internal class TextModel
return;
}
lastValidCol =
(IsSameRuneType (nRune, runeType) && Rune.IsLetterOrDigit (nRune)) || Rune.IsPunctuation (nRune) || Rune.IsSymbol (nRune)
? nCol
: lastValidCol;
if (fromRow != nRow)
{
nCol = 0;
@@ -477,6 +460,64 @@ internal class TextModel
}
}
public (int startCol, int col, int row)? ProcessDoubleClickSelection (int fromStartCol, int fromCol, int fromRow, bool useSameRuneType, bool selectWordOnly)
{
List<Cell> line = GetLine (fromRow);
int startCol = fromStartCol;
int col = fromCol;
int row = fromRow;
(int col, int row)? newPos = WordForward (col, row, useSameRuneType);
if (newPos.HasValue)
{
col = row == newPos.Value.row ? newPos.Value.col : 0;
}
if (startCol > 0
&& StringExtensions.ToString (line.GetRange (startCol, col - startCol).Select (c => c.Rune).ToList ()).Trim () == ""
&& (col - startCol > 1 || (col - startCol > 0 && line [startCol - 1].Rune == (Rune)' ')))
{
while (startCol > 0 && line [startCol - 1].Rune == (Rune)' ')
{
startCol--;
}
}
else
{
newPos = WordBackward (col, row, useSameRuneType);
if (newPos is { })
{
startCol = row == newPos.Value.row ? newPos.Value.col : line.Count;
}
}
if (selectWordOnly)
{
List<Rune> selRunes = line.GetRange (startCol, col - startCol).Select (c => c.Rune).ToList ();
if (StringExtensions.ToString (selRunes).Trim () != "")
{
for (int i = selRunes.Count - 1; i > -1; i--)
{
if (selRunes [i] == (Rune)' ')
{
col--;
}
}
}
}
if (fromStartCol != startCol || fromCol != col || fromRow != row)
{
return (startCol, col, row);
}
return null;
}
internal static int CalculateLeftColumn (List<Cell> t, int start, int end, int width, int tabWidth = 0)
{
List<Rune> runes = new ();
@@ -966,11 +1007,27 @@ internal class TextModel
return RuneType.IsUnknown;
}
private bool IsSameRuneType (Rune newRune, RuneType runeType)
private bool IsSameRuneType (Rune newRune, RuneType runeType, bool useSameRuneType)
{
RuneType rt = GetRuneType (newRune);
return rt == runeType;
if (useSameRuneType)
{
return rt == runeType;
}
switch (runeType)
{
case RuneType.IsSymbol:
case RuneType.IsPunctuation:
return rt is RuneType.IsSymbol or RuneType.IsPunctuation;
case RuneType.IsWhiteSpace:
case RuneType.IsLetterOrDigit:
case RuneType.IsUnknown:
return rt == runeType;
default:
throw new ArgumentOutOfRangeException (nameof (runeType), runeType, null);
}
}
private bool MatchWholeWord (string source, string matchText, int index = 0)
@@ -992,7 +1049,7 @@ internal class TextModel
return false;
}
private bool MoveNext (ref int col, ref int row, out Rune rune)
private bool MoveNext (ref int col, ref int row, out Rune rune, bool useSameRuneType)
{
List<Cell> line = GetLine (row);
@@ -1001,39 +1058,40 @@ internal class TextModel
col++;
rune = line [col].Rune;
if (col + 1 == line.Count && !Rune.IsLetterOrDigit (rune) && !Rune.IsWhiteSpace (line [col - 1].Rune))
if (col + 1 == line.Count
&& !Rune.IsLetterOrDigit (rune)
&& !Rune.IsWhiteSpace (line [col - 1].Rune)
&& IsSameRuneType (line [col - 1].Rune, GetRuneType (rune), useSameRuneType))
{
col++;
}
if (!Rune.IsWhiteSpace (rune)
&& (Rune.IsWhiteSpace (line [col - 1].Rune) || !IsSameRuneType (line [col - 1].Rune, GetRuneType (rune), useSameRuneType)))
{
return false;
}
return true;
}
if (col + 1 == line.Count)
{
col++;
rune = default (Rune);
return false;
}
while (row + 1 < Count)
{
col = 0;
row++;
line = GetLine (row);
if (line.Count > 0)
{
rune = line [0].Rune;
return true;
}
}
// End of line
col = 0;
row++;
rune = default (Rune);
return false;
}
private bool MovePrev (ref int col, ref int row, out Rune rune)
private bool MovePrev (ref int col, ref int row, out Rune rune, bool useSameRuneType)
{
List<Cell> line = GetLine (row);
@@ -1042,28 +1100,15 @@ internal class TextModel
col--;
rune = line [col].Rune;
return true;
}
if (row == 0)
{
rune = default (Rune);
return false;
}
while (row > 0)
{
row--;
line = GetLine (row);
col = line.Count - 1;
if (col >= 0)
if ((!Rune.IsWhiteSpace (rune)
&& !Rune.IsWhiteSpace (line [col + 1].Rune)
&& !IsSameRuneType (line [col + 1].Rune, GetRuneType (rune), useSameRuneType))
|| (Rune.IsWhiteSpace (rune) && !Rune.IsWhiteSpace (line [col + 1].Rune)))
{
rune = line [col].Rune;
return true;
return false;
}
return true;
}
rune = default (Rune);

View File

@@ -1015,6 +1015,19 @@ public class TextView : View, IDesignable
}
}
/// <summary>
/// Gets or sets whether the word forward and word backward navigation should use the same or equivalent rune type.
/// Default is <c>false</c> meaning using equivalent rune type.
/// </summary>
public bool UseSameRuneTypeForWords { get; set; }
/// <summary>
/// Gets or sets whether the word navigation should select only the word itself without spaces around it or with the
/// spaces at right.
/// Default is <c>false</c> meaning that the spaces at right are included in the selection.
/// </summary>
public bool SelectWordOnlyOnDoubleClick { get; set; }
/// <summary>Allows clearing the <see cref="HistoryTextItemEventArgs"/> items updating the original text.</summary>
public void ClearHistoryChanges () { _historyText?.Clear (_model.GetAllLines ()); }
@@ -1689,29 +1702,19 @@ public class TextView : View, IDesignable
}
ProcessMouseClick (ev, out List<Cell> line);
(int col, int row)? newPos;
if (CurrentColumn == line.Count
|| (CurrentColumn > 0 && (line [CurrentColumn - 1].Rune.Value != ' ' || line [CurrentColumn].Rune.Value == ' ')))
{
newPos = _model.WordBackward (CurrentColumn, CurrentRow);
if (newPos.HasValue)
{
CurrentColumn = CurrentRow == newPos.Value.row ? newPos.Value.col : 0;
}
}
if (!IsSelecting)
{
StartSelecting ();
}
newPos = _model.WordForward (CurrentColumn, CurrentRow);
(int startCol, int col, int row)? newPos = _model.ProcessDoubleClickSelection (SelectionStartColumn, CurrentColumn, CurrentRow, UseSameRuneTypeForWords, SelectWordOnlyOnDoubleClick);
if (newPos is { } && newPos.HasValue)
if (newPos.HasValue)
{
CurrentColumn = CurrentRow == newPos.Value.row ? newPos.Value.col : line.Count;
SelectionStartColumn = newPos.Value.startCol;
CurrentColumn = newPos.Value.col;
CurrentRow = newPos.Value.row;
}
PositionCursor ();
@@ -3380,7 +3383,7 @@ public class TextView : View, IDesignable
return;
}
(int col, int row)? newPos = _model.WordBackward (CurrentColumn, CurrentRow);
(int col, int row)? newPos = _model.WordBackward (CurrentColumn, CurrentRow, UseSameRuneTypeForWords);
if (newPos.HasValue && CurrentRow == newPos.Value.row)
{
@@ -3464,7 +3467,7 @@ public class TextView : View, IDesignable
return;
}
(int col, int row)? newPos = _model.WordForward (CurrentColumn, CurrentRow);
(int col, int row)? newPos = _model.WordForward (CurrentColumn, CurrentRow, UseSameRuneTypeForWords);
var restCount = 0;
if (newPos.HasValue && CurrentRow == newPos.Value.row)
@@ -3754,7 +3757,7 @@ public class TextView : View, IDesignable
private void MoveWordBackward ()
{
(int col, int row)? newPos = _model.WordBackward (CurrentColumn, CurrentRow);
(int col, int row)? newPos = _model.WordBackward (CurrentColumn, CurrentRow, UseSameRuneTypeForWords);
if (newPos.HasValue)
{
@@ -3767,7 +3770,7 @@ public class TextView : View, IDesignable
private void MoveWordForward ()
{
(int col, int row)? newPos = _model.WordForward (CurrentColumn, CurrentRow);
(int col, int row)? newPos = _model.WordForward (CurrentColumn, CurrentRow, UseSameRuneTypeForWords);
if (newPos.HasValue)
{