Skip to content

Commit 4583ce8

Browse files
author
Muaz
committed
Fix inverted RTL word/line caret navigation in the subtitle edit box
In a right-to-left subtitle, Ctrl+Left / Ctrl+Right jumped the wrong way and Ctrl+Up / Ctrl+Down did nothing. Avalonia (and AvaloniaEdit, used when color tags are on) move whole words by logical text offset, which runs backwards for RTL text; single-character steps were already visually correct. Handle these keys in the central OnKeyDownHandler - the same place the edit box already overrides keys (e.g. the Return-key line limiter) by marking the event handled in the root tunnel phase, which reliably pre-empts the editor's built-in navigation. A side TextBox/TextArea handler does not win against AvaloniaEdit, which is why this lives in the key pipeline instead. For right-to-left text in the focused edit box (detected by content, per line with a whole-box fallback - a subtitle is usually edited in an otherwise LtR window so FlowDirection stays LtR): - Ctrl+Left / Ctrl+Right move a word in the visually-correct direction. - Ctrl+Up / Ctrl+Down move to the start / end of the current line. - Shift extends the selection. - macOS Option+Left/Right (its native word modifier) is handled too. Movement goes through the ITextBoxWrapper abstraction, so it works for both the plain TextBox and the AvaloniaEdit TextEditor edit-box implementations. Left-to- right text keeps native navigation, and Home / End / Ctrl+Home / Ctrl+End are untouched.
1 parent b7db581 commit 4583ce8

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

src/ui/Features/Main/MainViewModel.cs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17549,6 +17549,165 @@ private static bool IsAlphaNumericWordChar(char c)
1754917549
Key.F24,
1755017550
};
1755117551

17552+
// Make Ctrl+Left/Right (word) and Ctrl+Up/Down (line head/end) move the caret in the visually
17553+
// correct direction when the focused edit box holds right-to-left text. Returns true (and marks
17554+
// the event handled) only when it acted, so left-to-right text keeps the platform's native
17555+
// navigation. Works for both edit-box implementations via the ITextBoxWrapper abstraction.
17556+
private bool TryHandleRightToLeftCaretNavigation(KeyEventArgs e)
17557+
{
17558+
var key = e.Key;
17559+
if (key != Key.Left && key != Key.Right && key != Key.Up && key != Key.Down)
17560+
{
17561+
return false;
17562+
}
17563+
17564+
var isWordKey = key == Key.Left || key == Key.Right;
17565+
var hasControl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
17566+
// Ctrl on every platform; also Option/Alt for word jumps on macOS (its native word modifier).
17567+
var isMacAltWord = isWordKey && e.KeyModifiers.HasFlag(KeyModifiers.Alt) &&
17568+
RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
17569+
if (!hasControl && !isMacAltWord)
17570+
{
17571+
return false;
17572+
}
17573+
17574+
var box = EditTextBox is { IsFocused: true } ? EditTextBox
17575+
: EditTextBoxOriginal is { IsFocused: true } ? EditTextBoxOriginal
17576+
: null;
17577+
if (box == null)
17578+
{
17579+
return false;
17580+
}
17581+
17582+
var text = box.Text ?? string.Empty;
17583+
var caret = Math.Clamp(box.CaretIndex, 0, text.Length);
17584+
if (!IsRightToLeftAtCaret(text, caret))
17585+
{
17586+
return false; // left-to-right text keeps native navigation
17587+
}
17588+
17589+
int target;
17590+
switch (key)
17591+
{
17592+
case Key.Left: // visually left == later in the logical RTL string
17593+
target = NextWordBoundary(text, caret, forward: true);
17594+
break;
17595+
case Key.Right: // visually right == earlier in the logical RTL string
17596+
target = NextWordBoundary(text, caret, forward: false);
17597+
break;
17598+
case Key.Up: // head of the current line
17599+
target = CurrentLineStart(text, caret);
17600+
break;
17601+
default: // Key.Down: end of the current line
17602+
target = CurrentLineEnd(text, caret);
17603+
break;
17604+
}
17605+
17606+
MoveCaretRtl(box, target, e.KeyModifiers.HasFlag(KeyModifiers.Shift));
17607+
e.Handled = true;
17608+
return true;
17609+
}
17610+
17611+
private static void MoveCaretRtl(ITextBoxWrapper box, int target, bool extendSelection)
17612+
{
17613+
var length = (box.Text ?? string.Empty).Length;
17614+
target = Math.Clamp(target, 0, length);
17615+
17616+
if (extendSelection)
17617+
{
17618+
var caret = box.CaretIndex;
17619+
int anchor;
17620+
if (box.SelectionLength > 0)
17621+
{
17622+
anchor = caret == box.SelectionEnd ? box.SelectionStart : box.SelectionEnd;
17623+
}
17624+
else
17625+
{
17626+
anchor = caret;
17627+
}
17628+
17629+
box.Select(Math.Min(anchor, target), Math.Abs(target - anchor));
17630+
box.CaretIndex = target;
17631+
}
17632+
else
17633+
{
17634+
box.ClearSelection();
17635+
box.CaretIndex = target;
17636+
}
17637+
}
17638+
17639+
// RTL by content: check the caret's line first (so a mixed multi-line box is handled per line),
17640+
// then fall back to the whole box when the line has no strong-directional letter.
17641+
private static bool IsRightToLeftAtCaret(string text, int caret)
17642+
{
17643+
var lineStart = CurrentLineStart(text, caret);
17644+
var lineEnd = CurrentLineEnd(text, caret);
17645+
for (var i = lineStart; i < lineEnd; i++)
17646+
{
17647+
if (IsRightToLeftLetter(text[i]))
17648+
{
17649+
return true;
17650+
}
17651+
}
17652+
17653+
foreach (var c in text)
17654+
{
17655+
if (IsRightToLeftLetter(c))
17656+
{
17657+
return true;
17658+
}
17659+
}
17660+
17661+
return false;
17662+
}
17663+
17664+
// Strong right-to-left scripts: Hebrew, Arabic (+ Supplement / Extended-A) and the Arabic
17665+
// presentation forms. Covers Arabic, Farsi and Urdu, which all use the Arabic block.
17666+
private static bool IsRightToLeftLetter(char c)
17667+
{
17668+
int u = c;
17669+
return (u >= 0x0590 && u <= 0x05FF) || // Hebrew
17670+
(u >= 0x0600 && u <= 0x06FF) || // Arabic
17671+
(u >= 0x0750 && u <= 0x077F) || // Arabic Supplement
17672+
(u >= 0x08A0 && u <= 0x08FF) || // Arabic Extended-A
17673+
(u >= 0xFB50 && u <= 0xFDFF) || // Arabic Presentation Forms-A
17674+
(u >= 0xFE70 && u <= 0xFEFF); // Arabic Presentation Forms-B
17675+
}
17676+
17677+
private static bool IsWordSeparator(char c) => char.IsWhiteSpace(c) || char.IsPunctuation(c) || char.IsSymbol(c);
17678+
17679+
// Mirrors the usual "Ctrl+Arrow" word stop: forward stops at the start of the next word,
17680+
// backward stops at the start of the current/previous word.
17681+
private static int NextWordBoundary(string text, int index, bool forward)
17682+
{
17683+
if (forward)
17684+
{
17685+
var i = index;
17686+
while (i < text.Length && !IsWordSeparator(text[i])) i++;
17687+
while (i < text.Length && IsWordSeparator(text[i])) i++;
17688+
return i;
17689+
}
17690+
17691+
var j = index;
17692+
while (j > 0 && IsWordSeparator(text[j - 1])) j--;
17693+
while (j > 0 && !IsWordSeparator(text[j - 1])) j--;
17694+
return j;
17695+
}
17696+
17697+
private static int CurrentLineStart(string text, int index)
17698+
{
17699+
var i = index;
17700+
while (i > 0 && text[i - 1] != '\n' && text[i - 1] != '\r') i--;
17701+
return i;
17702+
}
17703+
17704+
private static int CurrentLineEnd(string text, int index)
17705+
{
17706+
var i = index;
17707+
while (i < text.Length && text[i] != '\n' && text[i] != '\r') i++;
17708+
return i;
17709+
}
17710+
1755217711
internal void OnKeyDownHandler(object? sender, KeyEventArgs keyEventArgs)
1755317712
{
1755417713
lock (_onKeyDownHandlerLock)
@@ -17589,6 +17748,15 @@ internal void OnKeyDownHandler(object? sender, KeyEventArgs keyEventArgs)
1758917748

1759017749
if (IsTextInputFocused())
1759117750
{
17751+
// Right-to-left subtitles need visually-correct word / line caret movement:
17752+
// Avalonia (and AvaloniaEdit) move whole words by logical offset, which runs
17753+
// backwards for RTL text. Handle it here, where Handled reliably pre-empts the
17754+
// editor's built-in navigation (same path used for the Return-key limiter below).
17755+
if (TryHandleRightToLeftCaretNavigation(keyEventArgs))
17756+
{
17757+
return;
17758+
}
17759+
1759217760
// Bare and Ctrl+Left/Right are fundamental caret navigation in any
1759317761
// text input — never override them with shortcuts even when
1759417762
// "allow single-letter shortcuts in text box" is on (#11357).

0 commit comments

Comments
 (0)