Skip to content

Commit 4727683

Browse files
Implement UIA TextPattern for TextBox on Windows
TextBox previously only exposed ValuePattern on Windows, so screen readers (e.g. NVDA) had no way to track the caret or selection while editing: focusing the control announced the whole text once, but arrowing through it, selecting, or editing gave no further feedback. Adds a full TextPattern/TextRangeProvider implementation: - Avalonia.Automation.Provider.ITextProvider/ITextRangeProvider: new platform-agnostic contracts, implemented by TextBoxAutomationPeer and its nested TextRange type against TextBox/TextPresenter/ TextLayout (character/word/line navigation, selection, bounding rects). - AutomationPeer gains TextSelectionChanged/TextChanged events, raised by TextBoxAutomationPeer and coalesced so a single edit produces one UIA event rather than one per touched property. - Win32 glue (AutomationNode.Text.cs, Win32TextRangeProvider) wires the above into the existing GeneratedComInterface-based UIA provider, wrapping fresh ITextRangeProvider instances since, unlike AutomationNode, they aren't cached per-peer. Also fixes several bugs in the existing (previously unused/untested) Win32.Automation interop and marshalling code, found while getting TextPattern to work correctly with an out-of-process client: - ITextProvider.RangeFromPoint took two scalar doubles; the real UIA ABI passes a UiaPoint struct by value. - SafeArrayRef.CreateFromObjects used ComWrappers.TryGetComInstance, which only returns a wrapper that already exists rather than creating one. For objects with no prior COM exposure (unlike long-lived AutomationNode instances, which usually already have a wrapper via prior tree navigation) this silently left uninitialized ArrayPool-rented pointer data in the SAFEARRAY, corrupting it. - Interface-typed SAFEARRAYs were built with SafeArrayCreate instead of SafeArrayCreateEx, so they carried no IID (FADF_HAVEIID). Native code marshaling such an array to an out-of-process UIA client has no way to know which interface each element pointer is. - ITextRangeProvider.GetAttributeValue returned a plain -1 for unsupported attributes instead of UIA's reserved "not supported" sentinel (UiaGetReservedNotSupportedValue). At least NVDA reads a plain -1 as a real (truthy) attribute value; for the hyperlink attribute this made a plain TextBox get announced as a link. Adds TextBoxAutomationPeerTests covering the platform-agnostic layer: caret-degenerate selection, forward/reverse selection normalization, character-unit move/expand with clamping, word-unit expansion, bounding rectangles, and coalesced selection-changed events.
1 parent fc61f42 commit 4727683

15 files changed

Lines changed: 1199 additions & 32 deletions

File tree

src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,11 +576,34 @@ public abstract class AutomationPeer
576576
/// </summary>
577577
public event EventHandler<AutomationPropertyChangedEventArgs>? PropertyChanged;
578578

579+
/// <summary>
580+
/// Occurs when the text selection (or caret position, for a collapsed selection) of an
581+
/// <see cref="Provider.ITextProvider"/> peer has changed.
582+
/// </summary>
583+
public event EventHandler? TextSelectionChanged;
584+
585+
/// <summary>
586+
/// Occurs when the text content of an <see cref="Provider.ITextProvider"/> peer has
587+
/// changed.
588+
/// </summary>
589+
public event EventHandler? TextChanged;
590+
579591
/// <summary>
580592
/// Raises an event to notify the automation client the children of the peer have changed.
581593
/// </summary>
582594
protected void RaiseChildrenChangedEvent() => ChildrenChanged?.Invoke(this, EventArgs.Empty);
583595

596+
/// <summary>
597+
/// Raises an event to notify the automation client that the text selection or caret
598+
/// position has changed.
599+
/// </summary>
600+
protected void RaiseTextSelectionChangedEvent() => TextSelectionChanged?.Invoke(this, EventArgs.Empty);
601+
602+
/// <summary>
603+
/// Raises an event to notify the automation client that the text content has changed.
604+
/// </summary>
605+
protected void RaiseTextChangedEvent() => TextChanged?.Invoke(this, EventArgs.Empty);
606+
584607
/// <summary>
585608
/// Raises an event to notify the automation client of a changed property value.
586609
/// </summary>
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Avalonia.Automation.Provider;
5+
using Avalonia.Controls.Presenters;
6+
using Avalonia.Controls.Utils;
7+
using Avalonia.VisualTree;
8+
9+
namespace Avalonia.Automation.Peers
10+
{
11+
public partial class TextBoxAutomationPeer
12+
{
13+
/// <summary>
14+
/// A <c>[start,end)</c> character-index range into a <see cref="Controls.TextBox"/>'s text,
15+
/// implementing <see cref="ITextRangeProvider"/> against <see cref="TextPresenter"/>'s
16+
/// text layout. <see cref="_start"/> and <see cref="_end"/> are always kept normalized
17+
/// (<c>_start &lt;= _end</c>), clamped to <c>[0, Text.Length]</c>.
18+
/// </summary>
19+
private sealed class TextRange : ITextRangeProvider
20+
{
21+
private readonly TextBoxAutomationPeer _peer;
22+
private int _start;
23+
private int _end;
24+
25+
public TextRange(TextBoxAutomationPeer peer, int start, int end)
26+
{
27+
_peer = peer;
28+
Normalize(start, end);
29+
}
30+
31+
private string Text => _peer.Owner.Text ?? string.Empty;
32+
33+
private void Normalize(int start, int end)
34+
{
35+
if (start > end)
36+
(start, end) = (end, start);
37+
38+
var length = Text.Length;
39+
_start = Math.Clamp(start, 0, length);
40+
_end = Math.Clamp(end, 0, length);
41+
}
42+
43+
private int GetEndpoint(TextPatternRangeEndpoint endpoint) => endpoint == TextPatternRangeEndpoint.Start ? _start : _end;
44+
45+
public ITextRangeProvider Clone() => new TextRange(_peer, _start, _end);
46+
47+
public bool Compare(ITextRangeProvider range) =>
48+
range is TextRange other && other._peer == _peer && other._start == _start && other._end == _end;
49+
50+
public int CompareEndpoints(TextPatternRangeEndpoint endpoint, ITextRangeProvider targetRange, TextPatternRangeEndpoint targetEndpoint)
51+
{
52+
var target = (TextRange)targetRange;
53+
return GetEndpoint(endpoint).CompareTo(target.GetEndpoint(targetEndpoint));
54+
}
55+
56+
public void ExpandToEnclosingUnit(TextUnit unit)
57+
{
58+
var text = Text;
59+
var caretIndex = Math.Min(_start, text.Length);
60+
61+
switch (unit)
62+
{
63+
case TextUnit.Character:
64+
_start = caretIndex;
65+
_end = Math.Min(caretIndex + 1, text.Length);
66+
break;
67+
68+
case TextUnit.Word:
69+
{
70+
var start = caretIndex;
71+
var end = caretIndex;
72+
73+
if (!StringUtils.IsStartOfWord(text, caretIndex))
74+
start = StringUtils.PreviousWord(text, caretIndex);
75+
76+
if (!StringUtils.IsEndOfWord(text, caretIndex))
77+
end = StringUtils.NextWord(text, caretIndex);
78+
79+
Normalize(start, Math.Max(start, end));
80+
break;
81+
}
82+
83+
case TextUnit.Line:
84+
{
85+
var (lineStart, lineEnd) = GetLineBounds(caretIndex);
86+
Normalize(lineStart, lineEnd);
87+
break;
88+
}
89+
90+
case TextUnit.Paragraph:
91+
{
92+
var (paraStart, paraEnd) = GetParagraphBounds(text, caretIndex);
93+
Normalize(paraStart, paraEnd);
94+
break;
95+
}
96+
97+
// Format/Page/Document degenerate to the whole document: TextBox has no
98+
// formatting-attribute runs or pagination.
99+
case TextUnit.Format:
100+
case TextUnit.Page:
101+
case TextUnit.Document:
102+
default:
103+
Normalize(0, text.Length);
104+
break;
105+
}
106+
}
107+
108+
public ITextRangeProvider? FindAttribute(int attribute, object? value, bool backward) => null;
109+
110+
public ITextRangeProvider? FindText(string text, bool backward, bool ignoreCase)
111+
{
112+
var haystack = Text.Substring(_start, _end - _start);
113+
var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
114+
var index = backward
115+
? haystack.LastIndexOf(text, comparison)
116+
: haystack.IndexOf(text, comparison);
117+
118+
if (index < 0)
119+
return null;
120+
121+
var start = _start + index;
122+
return new TextRange(_peer, start, start + text.Length);
123+
}
124+
125+
public object GetAttributeValue(int attribute) => AutomationTextAttributeNotSupported.Instance;
126+
127+
public IReadOnlyList<Rect> GetBoundingRectangles()
128+
{
129+
var presenter = _peer.Owner.Presenter;
130+
if (presenter is null || _start == _end)
131+
return Array.Empty<Rect>();
132+
133+
if (presenter.GetVisualRoot() is not Visual root)
134+
return Array.Empty<Rect>();
135+
136+
var transform = presenter.TransformToVisual(root);
137+
if (transform is null)
138+
return Array.Empty<Rect>();
139+
140+
var rects = presenter.TextLayout.HitTestTextRange(_start, _end - _start);
141+
var result = new List<Rect>();
142+
143+
foreach (var rect in rects)
144+
{
145+
var snapped = PixelRect.FromRect(rect, 1).ToRect(1);
146+
result.Add(snapped.TransformToAABB(transform.Value));
147+
}
148+
149+
return result;
150+
}
151+
152+
public AutomationPeer GetEnclosingElement() => _peer;
153+
154+
public string GetText(int maxLength)
155+
{
156+
var text = Text.Substring(_start, _end - _start);
157+
return maxLength >= 0 && text.Length > maxLength ? text.Substring(0, maxLength) : text;
158+
}
159+
160+
public int Move(TextUnit unit, int count)
161+
{
162+
if (count == 0)
163+
return 0;
164+
165+
var length = _end - _start;
166+
var (newStart, moved) = MoveIndex(_start, unit, count);
167+
168+
Normalize(newStart, newStart + length);
169+
return moved;
170+
}
171+
172+
public int MoveEndpointByUnit(TextPatternRangeEndpoint endpoint, TextUnit unit, int count)
173+
{
174+
if (count == 0)
175+
return 0;
176+
177+
var (newValue, moved) = MoveIndex(GetEndpoint(endpoint), unit, count);
178+
179+
if (endpoint == TextPatternRangeEndpoint.Start)
180+
Normalize(newValue, Math.Max(newValue, _end));
181+
else
182+
Normalize(Math.Min(_start, newValue), newValue);
183+
184+
return moved;
185+
}
186+
187+
public void MoveEndpointByRange(TextPatternRangeEndpoint endpoint, ITextRangeProvider targetRange, TextPatternRangeEndpoint targetEndpoint)
188+
{
189+
var target = (TextRange)targetRange;
190+
var value = target.GetEndpoint(targetEndpoint);
191+
192+
if (endpoint == TextPatternRangeEndpoint.Start)
193+
Normalize(value, Math.Max(value, _end));
194+
else
195+
Normalize(Math.Min(_start, value), value);
196+
}
197+
198+
public void Select()
199+
{
200+
var owner = _peer.Owner;
201+
// CaretIndex's setter collapses SelectionStart/SelectionEnd to itself, so it must
202+
// be set first; setting SelectionStart/SelectionEnd afterwards only moves
203+
// CaretIndex back when the two endpoints end up equal (see TextBox's
204+
// OnSelectionStartChanged/OnSelectionEndChanged), so this order preserves the
205+
// intended [_start, _end) selection with the caret left at _end.
206+
owner.CaretIndex = _end;
207+
owner.SelectionStart = _start;
208+
owner.SelectionEnd = _end;
209+
}
210+
211+
public void AddToSelection() => Select();
212+
213+
public void RemoveFromSelection() => throw new NotSupportedException();
214+
215+
public void ScrollIntoView(bool alignToTop) => _peer.BringIntoView();
216+
217+
public IReadOnlyList<AutomationPeer> GetChildren() => Array.Empty<AutomationPeer>();
218+
219+
private (int NewIndex, int Moved) MoveIndex(int index, TextUnit unit, int count)
220+
{
221+
var text = Text;
222+
223+
switch (unit)
224+
{
225+
case TextUnit.Line:
226+
return MoveByLine(index, count);
227+
228+
case TextUnit.Word:
229+
return MoveByWord(text, index, count);
230+
231+
case TextUnit.Format:
232+
case TextUnit.Paragraph:
233+
case TextUnit.Page:
234+
case TextUnit.Document:
235+
{
236+
// Degenerates to Document: a single move jumps to the corresponding end.
237+
var target = count > 0 ? text.Length : 0;
238+
var moved = target == index ? 0 : (count > 0 ? 1 : -1);
239+
return (target, moved);
240+
}
241+
242+
case TextUnit.Character:
243+
default:
244+
{
245+
var target = Math.Clamp(index + count, 0, text.Length);
246+
return (target, target - index);
247+
}
248+
}
249+
}
250+
251+
private (int NewIndex, int Moved) MoveByWord(string text, int index, int count)
252+
{
253+
var pos = index;
254+
var moved = 0;
255+
256+
if (count > 0)
257+
{
258+
for (var i = 0; i < count; i++)
259+
{
260+
var next = StringUtils.NextWord(text, pos);
261+
if (next == pos)
262+
break;
263+
pos = next;
264+
moved++;
265+
}
266+
}
267+
else
268+
{
269+
for (var i = 0; i < -count; i++)
270+
{
271+
var prev = StringUtils.PreviousWord(text, pos);
272+
if (prev == pos)
273+
break;
274+
pos = prev;
275+
moved--;
276+
}
277+
}
278+
279+
return (pos, moved);
280+
}
281+
282+
private (int NewIndex, int Moved) MoveByLine(int index, int count)
283+
{
284+
var presenter = _peer.Owner.Presenter;
285+
if (presenter is null)
286+
return (index, 0);
287+
288+
var layout = presenter.TextLayout;
289+
var lineIndex = layout.GetLineIndexFromCharacterIndex(index, false);
290+
var targetLine = Math.Clamp(lineIndex + count, 0, layout.TextLines.Count - 1);
291+
var moved = targetLine - lineIndex;
292+
293+
return (layout.TextLines[targetLine].FirstTextSourceIndex, moved);
294+
}
295+
296+
private (int Start, int End) GetLineBounds(int index)
297+
{
298+
var presenter = _peer.Owner.Presenter;
299+
if (presenter is null)
300+
return (index, index);
301+
302+
var layout = presenter.TextLayout;
303+
var lineIndex = layout.GetLineIndexFromCharacterIndex(index, false);
304+
var line = layout.TextLines[lineIndex];
305+
306+
return (line.FirstTextSourceIndex, line.FirstTextSourceIndex + line.Length);
307+
}
308+
309+
private static (int Start, int End) GetParagraphBounds(string text, int index)
310+
{
311+
if (text.Length == 0)
312+
return (0, 0);
313+
314+
var start = text.LastIndexOf('\n', Math.Max(0, Math.Min(index, text.Length) - 1)) + 1;
315+
var end = text.IndexOf('\n', Math.Min(index, text.Length));
316+
if (end < 0)
317+
end = text.Length;
318+
else
319+
end += 1;
320+
321+
return (start, end);
322+
}
323+
}
324+
}
325+
}

0 commit comments

Comments
 (0)