Skip to content

Commit 960cc51

Browse files
author
Muaz
committed
Fix inverted RTL word/line caret navigation in the subtitle edit box
In a right-to-left subtitle, word and line caret navigation jumped the wrong way. 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), using the platform's standard navigation keys: - Word: Ctrl+Left/Right everywhere; Option(Alt)+Left/Right on macOS - moves a word in the visually-correct direction. - Line: Command(Meta)+Left/Right on macOS - moves to the visual start/end of the line. - Shift extends the selection. 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 non-arrow shortcuts (copy / paste / undo) plus Home / End are untouched.
1 parent b7db581 commit 960cc51

1 file changed

Lines changed: 196 additions & 0 deletions

File tree

src/ui/Features/Main/MainViewModel.cs

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

17552+
// Make word / line caret navigation move in the visually-correct direction when the focused edit
17553+
// box holds right-to-left text. Avalonia/AvaloniaEdit move whole words by logical offset, which is
17554+
// inverted for RTL. Returns true (and marks the event handled) only when it acted, so left-to-right
17555+
// text keeps native navigation. Works for both edit-box implementations via ITextBoxWrapper.
17556+
//
17557+
// Modifiers follow the platform's standard caret-navigation keys (Shift only extends the selection):
17558+
// word: Ctrl+Left/Right everywhere; Option(Alt)+Left/Right on macOS.
17559+
// line: Command(Meta)+Left/Right on macOS (move to the visual start/end of the line).
17560+
// bare Left/Right: only to collapse an existing selection to its visually-correct edge.
17561+
private bool TryHandleRightToLeftCaretNavigation(KeyEventArgs e)
17562+
{
17563+
var key = e.Key;
17564+
if (key != Key.Left && key != Key.Right)
17565+
{
17566+
return false;
17567+
}
17568+
17569+
var isMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
17570+
var navModifiers = e.KeyModifiers & ~KeyModifiers.Shift;
17571+
var isWordNav = navModifiers == KeyModifiers.Control || (isMac && navModifiers == KeyModifiers.Alt);
17572+
var isLineNav = isMac && navModifiers == KeyModifiers.Meta;
17573+
var isBareArrow = e.KeyModifiers == KeyModifiers.None;
17574+
if (!isWordNav && !isLineNav && !isBareArrow)
17575+
{
17576+
return false;
17577+
}
17578+
17579+
var box = EditTextBox is { IsFocused: true } ? EditTextBox
17580+
: EditTextBoxOriginal is { IsFocused: true } ? EditTextBoxOriginal
17581+
: null;
17582+
if (box == null)
17583+
{
17584+
return false;
17585+
}
17586+
17587+
var text = box.Text ?? string.Empty;
17588+
17589+
// Bare Left/Right: only intervene to collapse an existing selection to the visually-correct edge.
17590+
// With no selection, single-character movement is already bidi-correct, so leave it to the editor.
17591+
if (isBareArrow)
17592+
{
17593+
if (box.SelectionLength <= 0)
17594+
{
17595+
return false;
17596+
}
17597+
17598+
var caretNow = Math.Clamp(box.CaretIndex, 0, text.Length);
17599+
if (!IsRightToLeftAtCaret(text, caretNow))
17600+
{
17601+
return false; // left-to-right selection keeps native behaviour
17602+
}
17603+
17604+
// Visual edges in RTL: the left edge is the higher logical offset (SelectionEnd), the right
17605+
// edge the lower (SelectionStart). Capture before clearing, since clearing resets them.
17606+
var edge = key == Key.Left ? box.SelectionEnd : box.SelectionStart;
17607+
box.ClearSelection();
17608+
box.CaretIndex = Math.Clamp(edge, 0, text.Length);
17609+
_rtlSelectionAnchor = -1;
17610+
_rtlSelectionCaret = -1;
17611+
e.Handled = true;
17612+
return true;
17613+
}
17614+
17615+
var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
17616+
17617+
// While extending a selection, navigate from the moving end we tracked ourselves. AvaloniaEdit
17618+
// relocates the caret of a programmatic selection back to its anchor between key presses, so
17619+
// box.CaretIndex would otherwise recompute the same word every time and the selection wouldn't grow.
17620+
var continuingSelection = shift && _rtlSelectionAnchor >= 0 && box.SelectionLength > 0;
17621+
var from = Math.Clamp(continuingSelection ? _rtlSelectionCaret : box.CaretIndex, 0, text.Length);
17622+
17623+
if (!IsRightToLeftAtCaret(text, from))
17624+
{
17625+
_rtlSelectionAnchor = -1;
17626+
_rtlSelectionCaret = -1;
17627+
return false; // left-to-right text keeps native navigation
17628+
}
17629+
17630+
int target;
17631+
if (isLineNav)
17632+
{
17633+
// Visual line ends: Left -> the visual-left end (logical end in RTL); Right -> visual-right end.
17634+
target = key == Key.Left ? CurrentLineEnd(text, from) : CurrentLineStart(text, from);
17635+
}
17636+
else
17637+
{
17638+
// Word: Left moves visually left (== logical forward in RTL); Right moves visually right.
17639+
target = NextWordBoundary(text, from, forward: key == Key.Left);
17640+
}
17641+
17642+
if (shift)
17643+
{
17644+
var anchor = Math.Clamp(continuingSelection ? _rtlSelectionAnchor : from, 0, text.Length);
17645+
box.Select(Math.Min(anchor, target), Math.Abs(target - anchor));
17646+
_rtlSelectionAnchor = anchor;
17647+
_rtlSelectionCaret = target; // remember the moving end ourselves
17648+
}
17649+
else
17650+
{
17651+
box.ClearSelection();
17652+
box.CaretIndex = target;
17653+
_rtlSelectionAnchor = -1;
17654+
_rtlSelectionCaret = -1;
17655+
}
17656+
17657+
e.Handled = true;
17658+
return true;
17659+
}
17660+
17661+
// Fixed end of an in-progress Shift selection, and the moving end we last set. We track these
17662+
// ourselves because AvaloniaEdit relocates the caret of a programmatic selection back to its anchor
17663+
// between key presses, so reading the position back from the control breaks growing the selection.
17664+
private int _rtlSelectionAnchor = -1;
17665+
private int _rtlSelectionCaret = -1;
17666+
17667+
// RTL by content: check the caret's line first (so a mixed multi-line box is handled per line),
17668+
// then fall back to the whole box when the line has no strong-directional letter.
17669+
private static bool IsRightToLeftAtCaret(string text, int caret)
17670+
{
17671+
var lineStart = CurrentLineStart(text, caret);
17672+
var lineEnd = CurrentLineEnd(text, caret);
17673+
for (var i = lineStart; i < lineEnd; i++)
17674+
{
17675+
if (IsRightToLeftLetter(text[i]))
17676+
{
17677+
return true;
17678+
}
17679+
}
17680+
17681+
foreach (var c in text)
17682+
{
17683+
if (IsRightToLeftLetter(c))
17684+
{
17685+
return true;
17686+
}
17687+
}
17688+
17689+
return false;
17690+
}
17691+
17692+
// Strong right-to-left scripts: Hebrew, Arabic (+ Supplement / Extended-A) and the Arabic
17693+
// presentation forms. Covers Arabic, Farsi and Urdu, which all use the Arabic block.
17694+
private static bool IsRightToLeftLetter(char c)
17695+
{
17696+
int u = c;
17697+
return (u >= 0x0590 && u <= 0x05FF) || // Hebrew
17698+
(u >= 0x0600 && u <= 0x06FF) || // Arabic
17699+
(u >= 0x0750 && u <= 0x077F) || // Arabic Supplement
17700+
(u >= 0x08A0 && u <= 0x08FF) || // Arabic Extended-A
17701+
(u >= 0xFB50 && u <= 0xFDFF) || // Arabic Presentation Forms-A
17702+
(u >= 0xFE70 && u <= 0xFEFF); // Arabic Presentation Forms-B
17703+
}
17704+
17705+
private static bool IsWordSeparator(char c) => char.IsWhiteSpace(c) || char.IsPunctuation(c) || char.IsSymbol(c);
17706+
17707+
// Mirrors the usual "Ctrl+Arrow" word stop: forward stops at the start of the next word,
17708+
// backward stops at the start of the current/previous word.
17709+
private static int NextWordBoundary(string text, int index, bool forward)
17710+
{
17711+
if (forward)
17712+
{
17713+
var i = index;
17714+
while (i < text.Length && !IsWordSeparator(text[i])) i++;
17715+
while (i < text.Length && IsWordSeparator(text[i])) i++;
17716+
return i;
17717+
}
17718+
17719+
var j = index;
17720+
while (j > 0 && IsWordSeparator(text[j - 1])) j--;
17721+
while (j > 0 && !IsWordSeparator(text[j - 1])) j--;
17722+
return j;
17723+
}
17724+
17725+
private static int CurrentLineStart(string text, int index)
17726+
{
17727+
var i = index;
17728+
while (i > 0 && text[i - 1] != '\n' && text[i - 1] != '\r') i--;
17729+
return i;
17730+
}
17731+
17732+
private static int CurrentLineEnd(string text, int index)
17733+
{
17734+
var i = index;
17735+
while (i < text.Length && text[i] != '\n' && text[i] != '\r') i++;
17736+
return i;
17737+
}
17738+
1755217739
internal void OnKeyDownHandler(object? sender, KeyEventArgs keyEventArgs)
1755317740
{
1755417741
lock (_onKeyDownHandlerLock)
@@ -17589,6 +17776,15 @@ internal void OnKeyDownHandler(object? sender, KeyEventArgs keyEventArgs)
1758917776

1759017777
if (IsTextInputFocused())
1759117778
{
17779+
// Right-to-left subtitles need visually-correct word / line caret movement:
17780+
// Avalonia (and AvaloniaEdit) move whole words by logical offset, which runs
17781+
// backwards for RTL text. Handle it here, where Handled reliably pre-empts the
17782+
// editor's built-in navigation (same path used for the Return-key limiter below).
17783+
if (TryHandleRightToLeftCaretNavigation(keyEventArgs))
17784+
{
17785+
return;
17786+
}
17787+
1759217788
// Bare and Ctrl+Left/Right are fundamental caret navigation in any
1759317789
// text input — never override them with shortcuts even when
1759417790
// "allow single-letter shortcuts in text box" is on (#11357).

0 commit comments

Comments
 (0)