@@ -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