@@ -1711,6 +1711,7 @@ private static Avalonia.Controls.Control MakeTextBox(MainViewModel vm)
17111711 textBox . AddHandler ( InputElement . PointerPressedEvent , ( _ , e ) => vm . StoreTextEditorPointerArgs ( e ) , RoutingStrategies . Tunnel ) ;
17121712
17131713 SetupMacContextMenuForTextBox ( textBox , vm ) ;
1714+ AttachRightToLeftCaretNavigation ( textBox ) ;
17141715
17151716 vm . EditTextBox = new TextBoxWrapper ( textBox ) ;
17161717 return textBox ;
@@ -1821,6 +1822,7 @@ private static Avalonia.Controls.Control MakeTextBoxOriginal(MainViewModel vm)
18211822 }
18221823
18231824 SetupMacContextMenuForTextBox ( textBox , vm ) ;
1825+ AttachRightToLeftCaretNavigation ( textBox ) ;
18241826
18251827 vm . EditTextBoxOriginal = new TextBoxWrapper ( textBox ) ;
18261828 return textBox ;
@@ -1945,4 +1947,117 @@ private static void SetupMacContextMenu(TextEditor textEditor, MainViewModel vm)
19451947 } ,
19461948 RoutingStrategies . Tunnel ) ;
19471949 }
1950+
1951+ /// <summary>
1952+ /// Avalonia's TextBox moves the caret one character visually (bidi-aware) but moves whole words
1953+ /// by logical text offset. For a right-to-left subtitle that makes Ctrl+Left / Ctrl+Right jump
1954+ /// the wrong way (single-character steps are fine). This handler reimplements word movement in
1955+ /// the visually-correct direction for RTL text and adds Ctrl+Up / Ctrl+Down = start / end of the
1956+ /// current line. It only acts when the box is in RTL flow, so left-to-right editing and any global
1957+ /// Ctrl+Arrow shortcuts are left untouched.
1958+ /// </summary>
1959+ private static void AttachRightToLeftCaretNavigation ( TextBox textBox )
1960+ {
1961+ textBox . AddHandler (
1962+ InputElement . KeyDownEvent ,
1963+ ( _ , e ) =>
1964+ {
1965+ if ( textBox . FlowDirection != FlowDirection . RightToLeft ||
1966+ ! e . KeyModifiers . HasFlag ( KeyModifiers . Control ) )
1967+ {
1968+ return ;
1969+ }
1970+
1971+ var text = textBox . Text ?? string . Empty ;
1972+ var caret = Math . Clamp ( textBox . CaretIndex , 0 , text . Length ) ;
1973+ int target ;
1974+ switch ( e . Key )
1975+ {
1976+ case Key . Left : // visually left in RTL == later in the logical string
1977+ target = NextWordBoundary ( text , caret , forward : true ) ;
1978+ break ;
1979+ case Key . Right : // visually right in RTL == earlier in the logical string
1980+ target = NextWordBoundary ( text , caret , forward : false ) ;
1981+ break ;
1982+ case Key . Up : // head of the current line
1983+ target = CurrentLineStart ( text , caret ) ;
1984+ break ;
1985+ case Key . Down : // end of the current line
1986+ target = CurrentLineEnd ( text , caret ) ;
1987+ break ;
1988+ default :
1989+ return ;
1990+ }
1991+
1992+ MoveCaret ( textBox , target , e . KeyModifiers . HasFlag ( KeyModifiers . Shift ) ) ;
1993+ e . Handled = true ;
1994+ } ,
1995+ RoutingStrategies . Tunnel ) ;
1996+ }
1997+
1998+ private static bool IsWordSeparator ( char c ) => char . IsWhiteSpace ( c ) || char . IsPunctuation ( c ) || char . IsSymbol ( c ) ;
1999+
2000+ // Mirrors the usual "Ctrl+Arrow" word stop: forward stops at the start of the next word,
2001+ // backward stops at the start of the current/previous word.
2002+ private static int NextWordBoundary ( string text , int index , bool forward )
2003+ {
2004+ if ( forward )
2005+ {
2006+ var i = index ;
2007+ while ( i < text . Length && ! IsWordSeparator ( text [ i ] ) ) i ++ ;
2008+ while ( i < text . Length && IsWordSeparator ( text [ i ] ) ) i ++ ;
2009+ return i ;
2010+ }
2011+ else
2012+ {
2013+ var i = index ;
2014+ while ( i > 0 && IsWordSeparator ( text [ i - 1 ] ) ) i -- ;
2015+ while ( i > 0 && ! IsWordSeparator ( text [ i - 1 ] ) ) i -- ;
2016+ return i ;
2017+ }
2018+ }
2019+
2020+ private static int CurrentLineStart ( string text , int index )
2021+ {
2022+ var i = index ;
2023+ while ( i > 0 && text [ i - 1 ] != '\n ' && text [ i - 1 ] != '\r ' ) i -- ;
2024+ return i ;
2025+ }
2026+
2027+ private static int CurrentLineEnd ( string text , int index )
2028+ {
2029+ var i = index ;
2030+ while ( i < text . Length && text [ i ] != '\n ' && text [ i ] != '\r ' ) i ++ ;
2031+ return i ;
2032+ }
2033+
2034+ private static void MoveCaret ( TextBox textBox , int target , bool extendSelection )
2035+ {
2036+ var length = ( textBox . Text ?? string . Empty ) . Length ;
2037+ target = Math . Clamp ( target , 0 , length ) ;
2038+
2039+ if ( extendSelection )
2040+ {
2041+ // Keep the fixed end of the existing selection as the anchor; the caret follows
2042+ // SelectionEnd, so don't touch CaretIndex here (its setter can collapse the selection).
2043+ int anchor ;
2044+ if ( textBox . SelectionStart != textBox . SelectionEnd )
2045+ {
2046+ anchor = textBox . CaretIndex == textBox . SelectionEnd ? textBox . SelectionStart : textBox . SelectionEnd ;
2047+ }
2048+ else
2049+ {
2050+ anchor = textBox . CaretIndex ;
2051+ }
2052+
2053+ textBox . SelectionStart = anchor ;
2054+ textBox . SelectionEnd = target ;
2055+ }
2056+ else
2057+ {
2058+ textBox . CaretIndex = target ;
2059+ textBox . SelectionStart = target ;
2060+ textBox . SelectionEnd = target ;
2061+ }
2062+ }
19482063}
0 commit comments