|
10 | 10 | using Avalonia.Media; |
11 | 11 | using Avalonia.Styling; |
12 | 12 | using AvaloniaEdit; |
| 13 | +using AvaloniaEdit.Editing; |
13 | 14 | using Nikse.SubtitleEdit.Controls; |
14 | 15 | using Nikse.SubtitleEdit.Features.Shared.TextBoxUtils; |
15 | 16 | using Nikse.SubtitleEdit.Logic; |
@@ -1711,6 +1712,7 @@ private static Avalonia.Controls.Control MakeTextBox(MainViewModel vm) |
1711 | 1712 | textBox.AddHandler(InputElement.PointerPressedEvent, (_, e) => vm.StoreTextEditorPointerArgs(e), RoutingStrategies.Tunnel); |
1712 | 1713 |
|
1713 | 1714 | SetupMacContextMenuForTextBox(textBox, vm); |
| 1715 | + AttachRightToLeftCaretNavigation(textBox); |
1714 | 1716 |
|
1715 | 1717 | vm.EditTextBox = new TextBoxWrapper(textBox); |
1716 | 1718 | return textBox; |
@@ -1743,6 +1745,7 @@ private static Border MakeTextEditor(MainViewModel vm) |
1743 | 1745 | vm.EditTextBox = wrapper; |
1744 | 1746 |
|
1745 | 1747 | SetupMacContextMenu(textEditor, vm); |
| 1748 | + AttachRightToLeftCaretNavigation(textEditor); |
1746 | 1749 |
|
1747 | 1750 | var helper = new TextEditorBindingHelper(vm, textEditor, wrapper, textEditorBorder, defaultBorderBrush, focusedBorderBrush, isOriginal: false); |
1748 | 1751 | helper.Initialize(); |
@@ -1821,6 +1824,7 @@ private static Avalonia.Controls.Control MakeTextBoxOriginal(MainViewModel vm) |
1821 | 1824 | } |
1822 | 1825 |
|
1823 | 1826 | SetupMacContextMenuForTextBox(textBox, vm); |
| 1827 | + AttachRightToLeftCaretNavigation(textBox); |
1824 | 1828 |
|
1825 | 1829 | vm.EditTextBoxOriginal = new TextBoxWrapper(textBox); |
1826 | 1830 | return textBox; |
@@ -1853,6 +1857,7 @@ private static Border MakeTextEditorOriginal(MainViewModel vm) |
1853 | 1857 | vm.EditTextBoxOriginal = wrapper; |
1854 | 1858 |
|
1855 | 1859 | SetupMacContextMenu(textEditor, vm); |
| 1860 | + AttachRightToLeftCaretNavigation(textEditor); |
1856 | 1861 |
|
1857 | 1862 | var helper = new TextEditorBindingHelper(vm, textEditor, wrapper, textEditorBorder, defaultBorderBrush, focusedBorderBrush, isOriginal: true); |
1858 | 1863 | helper.Initialize(); |
@@ -1945,4 +1950,232 @@ private static void SetupMacContextMenu(TextEditor textEditor, MainViewModel vm) |
1945 | 1950 | }, |
1946 | 1951 | RoutingStrategies.Tunnel); |
1947 | 1952 | } |
| 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 | + } |
1948 | 2181 | } |
0 commit comments