Skip to content

Commit 01b3ba8

Browse files
author
Muaz
committed
Fix inverted RTL word navigation in the subtitle edit box
In a right-to-left subtitle, Ctrl+Left / Ctrl+Right jumped the wrong way. Avalonia moves the caret one character visually (bidi-aware) but moves whole words by logical text offset, so word steps run opposite to the visual direction in RTL text (single-character steps were already correct). Ctrl+Up / Ctrl+Down also did nothing useful. Add a tunnel-phase key handler that, for right-to-left text: - 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. The trigger is the *content* being right-to-left, detected per line (Hebrew / Arabic blocks) with a whole-box fallback, not the control's FlowDirection: a subtitle is normally edited in an otherwise left-to-right window, so the box stays LtR even for Arabic. The subtitle edit box has two implementations: a plain TextBox and an AvaloniaEdit TextEditor (used when color tags are enabled). The handler is wired to both - the TextEditor variant uses a tunnel handler on TextArea, the same pattern already used here to pre-empt AvaloniaEdit's mouse handling - so the fix applies regardless of the color-tags setting. Left-to-right lines keep the default behaviour, so LTR editing and global Ctrl+Arrow shortcuts are unchanged.
1 parent b7db581 commit 01b3ba8

1 file changed

Lines changed: 233 additions & 0 deletions

File tree

src/ui/Features/Main/Layout/InitListViewAndEditBox.cs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Avalonia.Media;
1111
using Avalonia.Styling;
1212
using AvaloniaEdit;
13+
using AvaloniaEdit.Editing;
1314
using Nikse.SubtitleEdit.Controls;
1415
using Nikse.SubtitleEdit.Features.Shared.TextBoxUtils;
1516
using Nikse.SubtitleEdit.Logic;
@@ -1711,6 +1712,7 @@ private static Avalonia.Controls.Control MakeTextBox(MainViewModel vm)
17111712
textBox.AddHandler(InputElement.PointerPressedEvent, (_, e) => vm.StoreTextEditorPointerArgs(e), RoutingStrategies.Tunnel);
17121713

17131714
SetupMacContextMenuForTextBox(textBox, vm);
1715+
AttachRightToLeftCaretNavigation(textBox);
17141716

17151717
vm.EditTextBox = new TextBoxWrapper(textBox);
17161718
return textBox;
@@ -1743,6 +1745,7 @@ private static Border MakeTextEditor(MainViewModel vm)
17431745
vm.EditTextBox = wrapper;
17441746

17451747
SetupMacContextMenu(textEditor, vm);
1748+
AttachRightToLeftCaretNavigation(textEditor);
17461749

17471750
var helper = new TextEditorBindingHelper(vm, textEditor, wrapper, textEditorBorder, defaultBorderBrush, focusedBorderBrush, isOriginal: false);
17481751
helper.Initialize();
@@ -1821,6 +1824,7 @@ private static Avalonia.Controls.Control MakeTextBoxOriginal(MainViewModel vm)
18211824
}
18221825

18231826
SetupMacContextMenuForTextBox(textBox, vm);
1827+
AttachRightToLeftCaretNavigation(textBox);
18241828

18251829
vm.EditTextBoxOriginal = new TextBoxWrapper(textBox);
18261830
return textBox;
@@ -1853,6 +1857,7 @@ private static Border MakeTextEditorOriginal(MainViewModel vm)
18531857
vm.EditTextBoxOriginal = wrapper;
18541858

18551859
SetupMacContextMenu(textEditor, vm);
1860+
AttachRightToLeftCaretNavigation(textEditor);
18561861

18571862
var helper = new TextEditorBindingHelper(vm, textEditor, wrapper, textEditorBorder, defaultBorderBrush, focusedBorderBrush, isOriginal: true);
18581863
helper.Initialize();
@@ -1945,4 +1950,232 @@ private static void SetupMacContextMenu(TextEditor textEditor, MainViewModel vm)
19451950
},
19461951
RoutingStrategies.Tunnel);
19471952
}
1953+
1954+
/// <summary>
1955+
/// Avalonia's TextBox moves the caret one character visually (bidi-aware) but moves whole words
1956+
/// by logical text offset. For right-to-left text that makes Ctrl+Left / Ctrl+Right jump the wrong
1957+
/// way (single-character steps are fine). This handler reimplements word movement in the
1958+
/// visually-correct direction for RTL text and adds Ctrl+Up / Ctrl+Down = start / end of the
1959+
/// current line. The trigger is the *content* being right-to-left - a subtitle is normally edited
1960+
/// in an otherwise left-to-right window, so the box's FlowDirection stays LtR even for Arabic. So
1961+
/// it keys off the text, leaving left-to-right lines and any global Ctrl+Arrow shortcuts untouched.
1962+
/// </summary>
1963+
private static void AttachRightToLeftCaretNavigation(TextBox textBox)
1964+
{
1965+
textBox.AddHandler(
1966+
InputElement.KeyDownEvent,
1967+
(_, e) =>
1968+
{
1969+
if (!e.KeyModifiers.HasFlag(KeyModifiers.Control))
1970+
{
1971+
return;
1972+
}
1973+
1974+
var text = textBox.Text ?? string.Empty;
1975+
var caret = Math.Clamp(textBox.CaretIndex, 0, text.Length);
1976+
1977+
if (!IsRightToLeftContext(textBox.FlowDirection, text, caret) ||
1978+
!TryComputeRtlCaretTarget(e.Key, text, caret, out var target))
1979+
{
1980+
return;
1981+
}
1982+
1983+
MoveCaret(textBox, target, e.KeyModifiers.HasFlag(KeyModifiers.Shift));
1984+
e.Handled = true;
1985+
},
1986+
RoutingStrategies.Tunnel);
1987+
}
1988+
1989+
// The subtitle edit box is an AvaloniaEdit TextEditor (not a plain TextBox) whenever color tags
1990+
// are enabled, so the same right-to-left caret handling has to be wired up here too - otherwise
1991+
// word navigation falls back to AvaloniaEdit's logical (inverted for RTL) behaviour.
1992+
private static void AttachRightToLeftCaretNavigation(TextEditor textEditor)
1993+
{
1994+
textEditor.TextArea.AddHandler(
1995+
InputElement.KeyDownEvent,
1996+
(_, e) =>
1997+
{
1998+
if (!e.KeyModifiers.HasFlag(KeyModifiers.Control))
1999+
{
2000+
return;
2001+
}
2002+
2003+
var text = textEditor.Document?.Text ?? string.Empty;
2004+
var caret = Math.Clamp(textEditor.CaretOffset, 0, text.Length);
2005+
2006+
if (!IsRightToLeftContext(textEditor.TextArea.FlowDirection, text, caret) ||
2007+
!TryComputeRtlCaretTarget(e.Key, text, caret, out var target))
2008+
{
2009+
return;
2010+
}
2011+
2012+
MoveCaret(textEditor, target, e.KeyModifiers.HasFlag(KeyModifiers.Shift));
2013+
e.Handled = true;
2014+
},
2015+
RoutingStrategies.Tunnel);
2016+
}
2017+
2018+
private static bool TryComputeRtlCaretTarget(Key key, string text, int caret, out int target)
2019+
{
2020+
switch (key)
2021+
{
2022+
case Key.Left: // visually left in RTL == later in the logical string
2023+
target = NextWordBoundary(text, caret, forward: true);
2024+
return true;
2025+
case Key.Right: // visually right in RTL == earlier in the logical string
2026+
target = NextWordBoundary(text, caret, forward: false);
2027+
return true;
2028+
case Key.Up: // head of the current line
2029+
target = CurrentLineStart(text, caret);
2030+
return true;
2031+
case Key.Down: // end of the current line
2032+
target = CurrentLineEnd(text, caret);
2033+
return true;
2034+
default:
2035+
target = caret;
2036+
return false;
2037+
}
2038+
}
2039+
2040+
// True when caret-direction handling should follow right-to-left visual order: either the box is
2041+
// explicitly in RTL flow, or the text is RTL by content. We look at the current line first (so a
2042+
// mixed multi-line box is handled per line) and fall back to the whole box when the current line
2043+
// has no strong-directional letter (e.g. it's empty or only digits).
2044+
private static bool IsRightToLeftContext(FlowDirection flowDirection, string text, int caret)
2045+
{
2046+
if (flowDirection == FlowDirection.RightToLeft)
2047+
{
2048+
return true;
2049+
}
2050+
2051+
var lineStart = CurrentLineStart(text, caret);
2052+
var lineEnd = CurrentLineEnd(text, caret);
2053+
for (var i = lineStart; i < lineEnd; i++)
2054+
{
2055+
if (IsRightToLeftLetter(text[i]))
2056+
{
2057+
return true;
2058+
}
2059+
}
2060+
2061+
foreach (var c in text)
2062+
{
2063+
if (IsRightToLeftLetter(c))
2064+
{
2065+
return true;
2066+
}
2067+
}
2068+
2069+
return false;
2070+
}
2071+
2072+
// Strong right-to-left scripts: Hebrew, Arabic (+ Supplement / Extended-A) and the Arabic
2073+
// presentation forms. Covers Arabic, Farsi and Urdu, which all use the Arabic block.
2074+
private static bool IsRightToLeftLetter(char c) =>
2075+
(c >= '\u0590' && c <= '\u05FF') || // Hebrew
2076+
(c >= '\u0600' && c <= '\u06FF') || // Arabic
2077+
(c >= '\u0750' && c <= '\u077F') || // Arabic Supplement
2078+
(c >= '\u08A0' && c <= '\u08FF') || // Arabic Extended-A
2079+
(c >= '\uFB50' && c <= '\uFDFF') || // Arabic Presentation Forms-A
2080+
(c >= '\uFE70' && c <= '\uFEFF'); // Arabic Presentation Forms-B
2081+
2082+
private static bool IsWordSeparator(char c) => char.IsWhiteSpace(c) || char.IsPunctuation(c) || char.IsSymbol(c);
2083+
2084+
// Mirrors the usual "Ctrl+Arrow" word stop: forward stops at the start of the next word,
2085+
// backward stops at the start of the current/previous word.
2086+
private static int NextWordBoundary(string text, int index, bool forward)
2087+
{
2088+
if (forward)
2089+
{
2090+
var i = index;
2091+
while (i < text.Length && !IsWordSeparator(text[i])) i++;
2092+
while (i < text.Length && IsWordSeparator(text[i])) i++;
2093+
return i;
2094+
}
2095+
else
2096+
{
2097+
var i = index;
2098+
while (i > 0 && IsWordSeparator(text[i - 1])) i--;
2099+
while (i > 0 && !IsWordSeparator(text[i - 1])) i--;
2100+
return i;
2101+
}
2102+
}
2103+
2104+
private static int CurrentLineStart(string text, int index)
2105+
{
2106+
var i = index;
2107+
while (i > 0 && text[i - 1] != '\n' && text[i - 1] != '\r') i--;
2108+
return i;
2109+
}
2110+
2111+
private static int CurrentLineEnd(string text, int index)
2112+
{
2113+
var i = index;
2114+
while (i < text.Length && text[i] != '\n' && text[i] != '\r') i++;
2115+
return i;
2116+
}
2117+
2118+
private static void MoveCaret(TextBox textBox, int target, bool extendSelection)
2119+
{
2120+
var length = (textBox.Text ?? string.Empty).Length;
2121+
target = Math.Clamp(target, 0, length);
2122+
2123+
if (extendSelection)
2124+
{
2125+
// Keep the fixed end of the existing selection as the anchor; the caret follows
2126+
// SelectionEnd, so don't touch CaretIndex here (its setter can collapse the selection).
2127+
int anchor;
2128+
if (textBox.SelectionStart != textBox.SelectionEnd)
2129+
{
2130+
anchor = textBox.CaretIndex == textBox.SelectionEnd ? textBox.SelectionStart : textBox.SelectionEnd;
2131+
}
2132+
else
2133+
{
2134+
anchor = textBox.CaretIndex;
2135+
}
2136+
2137+
textBox.SelectionStart = anchor;
2138+
textBox.SelectionEnd = target;
2139+
}
2140+
else
2141+
{
2142+
textBox.CaretIndex = target;
2143+
textBox.SelectionStart = target;
2144+
textBox.SelectionEnd = target;
2145+
}
2146+
}
2147+
2148+
private static void MoveCaret(TextEditor textEditor, int target, bool extendSelection)
2149+
{
2150+
var area = textEditor.TextArea;
2151+
var length = textEditor.Document?.TextLength ?? 0;
2152+
target = Math.Clamp(target, 0, length);
2153+
2154+
if (extendSelection)
2155+
{
2156+
// Anchor on the fixed end of any existing selection so Shift keeps extending it.
2157+
var caret = textEditor.CaretOffset;
2158+
int anchor;
2159+
if (textEditor.SelectionLength > 0)
2160+
{
2161+
var selStart = textEditor.SelectionStart;
2162+
var selEnd = selStart + textEditor.SelectionLength;
2163+
anchor = caret == selEnd ? selStart : selEnd;
2164+
}
2165+
else
2166+
{
2167+
anchor = caret;
2168+
}
2169+
2170+
area.Selection = Selection.Create(area, anchor, target);
2171+
area.Caret.Offset = target;
2172+
}
2173+
else
2174+
{
2175+
area.ClearSelection();
2176+
area.Caret.Offset = target;
2177+
}
2178+
2179+
area.Caret.BringCaretToView();
2180+
}
19482181
}

0 commit comments

Comments
 (0)