A cross-platform UI framework for .NET
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

500 lines
19 KiB

using System;
using System.Runtime.InteropServices;
using Avalonia.Automation.Provider;
using Avalonia.Utilities;
using Avalonia.Win32.Interop.Automation;
using AAP = Avalonia.Automation.Provider;
#nullable enable
namespace Avalonia.Win32.Automation
{
internal class AutomationTextRange : ITextRangeProvider
{
private readonly AutomationNode _owner;
private int _start;
private int _end;
public AutomationTextRange(AutomationNode owner, TextRange range)
: this(owner, range.Start, range.End)
{
}
public AutomationTextRange(AutomationNode owner, int start, int end)
{
_owner = owner;
_start = start;
_end = end;
}
public int Start
{
get => _start;
private set
{
if (value < 0)
throw new InvalidOperationException();
if (value > _end)
_end = value;
_start = value;
}
}
public int End
{
get => _end;
private set
{
if (value < 0)
throw new InvalidOperationException();
if (value < _start)
_start = value;
_end = value;
}
}
public TextRange Range => new(Start, End);
private AAP.ITextProvider InnerProvider => (AAP.ITextProvider)_owner.Peer;
public ITextRangeProvider Clone() => new AutomationTextRange(_owner, Range);
[return: MarshalAs(UnmanagedType.Bool)]
public bool Compare(ITextRangeProvider range)
{
return range is AutomationTextRange other && other.Start == Start && other.End == End;
}
public int CompareEndpoints(
TextPatternRangeEndpoint endpoint,
ITextRangeProvider targetRange,
TextPatternRangeEndpoint targetEndpoint)
{
var other = targetRange as AutomationTextRange ?? throw new InvalidOperationException("Invalid text range");
var e1 = (endpoint == TextPatternRangeEndpoint.Start) ? Start : End;
var e2 = (targetEndpoint == TextPatternRangeEndpoint.Start) ? other.Start : other.End;
return e1 - e2;
}
public void ExpandToEnclosingUnit(TextUnit unit)
{
_owner.InvokeSync(() =>
{
var text = InnerProvider.GetText(InnerProvider.DocumentRange);
switch (unit)
{
case TextUnit.Character:
if (Start == End)
End = MoveEndpointForward(text, End, TextUnit.Character, 1, out _);
break;
case TextUnit.Word:
// Move start left until we reach a word boundary.
for (; !AtWordBoundary(text, Start); Start--)
;
// Move end right until we reach word boundary (different from Start).
End = Math.Min(Math.Max(End, Start + 1), text.Length);
for (; !AtWordBoundary(text, End); End++)
;
break;
case TextUnit.Line:
if (InnerProvider.LineCount != 1)
{
int startLine = InnerProvider.GetLineForIndex(Start);
int endLine = InnerProvider.GetLineForIndex(End);
var start = InnerProvider.GetLineRange(startLine).Start;
var end = InnerProvider.GetLineRange(endLine).End;
MoveTo(start, end);
}
else
{
MoveTo(0, text.Length);
}
break;
case TextUnit.Paragraph:
// Move start left until we reach a paragraph boundary.
for (; !AtParagraphBoundary(text, Start); Start--)
;
// Move end right until we reach a paragraph boundary (different from Start).
End = Math.Min(Math.Max(End, Start + 1), text.Length);
for (; !AtParagraphBoundary(text, End); End++)
;
break;
case TextUnit.Format:
case TextUnit.Page:
case TextUnit.Document:
MoveTo(0, text.Length);
break;
default:
throw new ArgumentException("Invalid TextUnit.", nameof(unit));
}
});
}
public ITextRangeProvider? FindAttribute(
TextPatternAttribute attribute,
object? value,
[MarshalAs(UnmanagedType.Bool)] bool backward)
{
// TODO: Implement.
return null;
}
public ITextRangeProvider? FindText(
string text,
[MarshalAs(UnmanagedType.Bool)] bool backward,
[MarshalAs(UnmanagedType.Bool)] bool ignoreCase)
{
return _owner.InvokeSync(() =>
{
var rangeText = InnerProvider.GetText(Range);
if (ignoreCase)
{
rangeText = rangeText.ToLowerInvariant();
text = text.ToLowerInvariant();
}
var i = backward ?
rangeText.LastIndexOf(text, StringComparison.Ordinal) :
rangeText.IndexOf(text, StringComparison.Ordinal);
return i >= 0 ? new AutomationTextRange(_owner, Start + i, Start + i + text.Length) : null;
});
}
public object? GetAttributeValue(TextPatternAttribute attribute)
{
return attribute switch
{
TextPatternAttribute.IsReadOnlyAttributeId => _owner.InvokeSync(() => InnerProvider.IsReadOnly),
_ => null
};
}
public double[] GetBoundingRectangles()
{
return _owner.InvokeSync(() =>
{
var rects = InnerProvider.GetBounds(Range);
var result = new double[rects.Count * 4];
var root = _owner.GetRoot() as RootAutomationNode;
if (root is object)
{
for (var i = 0; i < rects.Count; i++)
{
var screenRect = root.ToScreen(rects[i]);
result[4 * i] = screenRect.X;
result[4 * i + 1] = screenRect.Y;
result[4 * i + 2] = screenRect.Width;
result[4 * i + 3] = screenRect.Height;
}
}
return result;
});
}
public IRawElementProviderSimple[] GetChildren() => Array.Empty<IRawElementProviderSimple>();
public IRawElementProviderSimple GetEnclosingElement() => _owner;
public string GetText(int maxLength)
{
if (maxLength < 0)
maxLength = int.MaxValue;
maxLength = Math.Min(maxLength, End - Start);
return _owner.InvokeSync(() => InnerProvider.GetText(new TextRange(Start, maxLength)));
}
public int Move(TextUnit unit, int count)
{
return _owner.InvokeSync(() =>
{
if (count == 0)
return 0;
var text = InnerProvider.GetText(new(0, int.MaxValue));
// Save the start and end in case the move fails.
var oldStart = Start;
var oldEnd = End;
var wasDegenerate = Start == End;
// Move the start of the text range forward or backward in the document by the
// requested number of text unit boundaries.
var moved = MoveEndpointByUnit(TextPatternRangeEndpoint.Start, unit, count);
var succeeded = moved != 0;
if (succeeded)
{
// Make the range degenerate at the new start point.
End = Start;
// If we previously had a non-degenerate range then expand the range.
if (!wasDegenerate)
{
var forwards = count > 0;
if (forwards && Start == text.Length - 1)
{
// The start is at the end of the document, so move the start backward by
// one text unit to expand the text range from the degenerate range state.
Start = MoveEndpointBackward(text, Start, unit, -1, out var expandMoved);
--moved;
succeeded = expandMoved == -1 && moved > 0;
}
else
{
// The start is not at the end of the document, so move the endpoint
// forward by one text unit to expand the text range from the degenerate
// state.
End = MoveEndpointForward(text, End, unit, 1, out var expandMoved);
succeeded = expandMoved > 0;
}
}
}
if (!succeeded)
{
Start = oldStart;
End = oldEnd;
moved = 0;
}
return moved;
});
}
public void MoveEndpointByRange(TextPatternRangeEndpoint endpoint, ITextRangeProvider targetRange, TextPatternRangeEndpoint targetEndpoint)
{
var editRange = targetRange as AutomationTextRange ?? throw new InvalidOperationException("Invalid text range");
var e = (targetEndpoint == TextPatternRangeEndpoint.Start) ? editRange.Start : editRange.End;
if (endpoint == TextPatternRangeEndpoint.Start)
Start = e;
else
End = e;
}
public int MoveEndpointByUnit(TextPatternRangeEndpoint endpoint, TextUnit unit, int count)
{
if (count == 0)
return 0;
return _owner.InvokeSync(() =>
{
var text = InnerProvider.GetText(InnerProvider.DocumentRange);
var moved = 0;
var moveStart = endpoint == TextPatternRangeEndpoint.Start;
if (count > 0)
{
if (moveStart)
Start = MoveEndpointForward(text, Start, unit, count, out moved);
else
End = MoveEndpointForward(text, End, unit, count, out moved);
}
else if (count < 0)
{
if (moveStart)
Start = MoveEndpointBackward(text, Start, unit, count, out moved);
else
End = MoveEndpointBackward(text, End, unit, count, out moved);
}
return moved;
});
}
void ITextRangeProvider.AddToSelection() => throw new NotSupportedException();
void ITextRangeProvider.RemoveFromSelection() => throw new NotSupportedException();
public void ScrollIntoView([MarshalAs(UnmanagedType.Bool)] bool alignToTop)
{
_owner.InvokeSync(() => InnerProvider.ScrollIntoView(Range));
}
public void Select()
{
_owner.InvokeSync(() => InnerProvider.Select(Range));
}
private static bool AtParagraphBoundary(string text, int index)
{
return index <= 0 || index >= text.Length || (text[index] == '\r') && (text[index] != '\n');
}
// returns true iff index identifies a word boundary within text.
// following richedit & word precedent the boundaries are at the leading edge of the word
// so the span of a word includes trailing whitespace.
private static bool AtWordBoundary(string text, int index)
{
// we are at a word boundary if we are at the beginning or end of the text
if (index <= 0 || index >= text.Length)
{
return true;
}
if (AtParagraphBoundary(text, index))
{
return true;
}
var ch1 = text[index - 1];
var ch2 = text[index];
// an apostrophe does *not* break a word if it follows or precedes characters
if ((char.IsLetterOrDigit(ch1) && IsApostrophe(ch2))
|| (IsApostrophe(ch1) && char.IsLetterOrDigit(ch2) && index >= 2 && char.IsLetterOrDigit(text[index - 2])))
{
return false;
}
// the following transitions mark boundaries.
// note: these are constructed to include trailing whitespace.
return (char.IsWhiteSpace(ch1) && !char.IsWhiteSpace(ch2))
|| (char.IsLetterOrDigit(ch1) && !char.IsLetterOrDigit(ch2))
|| (!char.IsLetterOrDigit(ch1) && char.IsLetterOrDigit(ch2))
|| (char.IsPunctuation(ch1) && char.IsWhiteSpace(ch2));
}
private static bool IsApostrophe(char ch)
{
return ch == '\'' ||
ch == (char)0x2019; // Unicode Right Single Quote Mark
}
private static bool IsWordBreak(char ch)
{
return char.IsWhiteSpace(ch) || char.IsPunctuation(ch);
}
private int MoveEndpointForward(string text, int index, TextUnit unit, int count, out int moved)
{
var limit = text.Length;
switch (unit)
{
case TextUnit.Character:
moved = Math.Min(count, limit - index);
index = index + moved;
index = index > limit ? limit : index;
break;
case TextUnit.Word:
// TODO: This will need to implement the Unicode word boundaries spec.
for (moved = 0; moved < count && index < text.Length; moved++)
{
for (index++; !AtWordBoundary(text, index); index++)
;
for (; index < text.Length && IsWordBreak(text[index]); index++)
;
}
break;
case TextUnit.Line:
{
var line = InnerProvider.GetLineForIndex(index);
var newLine = MathUtilities.Clamp(line + count, 0, InnerProvider.LineCount - 1);
index = InnerProvider.GetLineRange(newLine).Start;
moved = newLine - line;
}
break;
case TextUnit.Paragraph:
for (moved = 0; moved < count && index < text.Length; moved++)
{
for (index++; !AtParagraphBoundary(text, index); index++)
;
}
break;
case TextUnit.Format:
case TextUnit.Page:
case TextUnit.Document:
moved = index < limit ? 1 : 0;
index = limit;
break;
default:
throw new ArgumentException("Invalid TextUnit.", nameof(unit));
}
return index;
}
// moves an endpoint backward a certain number of units.
// the endpoint is just an index into the text so it could represent either
// the endpoint.
private int MoveEndpointBackward(string text, int index, TextUnit unit, int count, out int moved)
{
if (index == 0)
{
moved = 0;
return 0;
}
switch (unit)
{
case TextUnit.Character:
int oneBasedIndex = index + 1;
moved = Math.Max(count, -oneBasedIndex);
index += moved;
index = index < 0 ? 0 : index;
break;
case TextUnit.Word:
for (moved = 0; moved > count && index > 0; moved--)
{
for (index--; index >= 0 && IsWordBreak(text[index]); index--)
;
for (index--; !AtWordBoundary(text, index); index--)
;
}
break;
case TextUnit.Line:
{
var line = InnerProvider.GetLineForIndex(index);
// If a line other than the first consists of only a newline, then you can
// move backwards past this line and the position changes, hence this is
// counted. The first line is special, though: if it is empty, and you move
// say from the second line back up to the first, you cannot move further.
// However if the first line is nonempty, you can move from the end of the
// first line to its beginning! This latter move is counted, but if the
// first line is empty, it is not counted.
if (line == 0)
{
index = 0;
moved = !IsEol(text[0]) ? -1 : 0;
}
else
{
var newLine = MathUtilities.Clamp(line + count, 0, InnerProvider.LineCount - 1);
index = InnerProvider.GetLineRange(newLine).Start;
moved = newLine - line;
}
}
break;
case TextUnit.Paragraph:
for (moved = 0; moved > count && index > 0; moved--)
{
for (index--; !AtParagraphBoundary(text, index); index--)
;
}
break;
case TextUnit.Format:
case TextUnit.Page:
case TextUnit.Document:
moved = index > 0 ? -1 : 0;
index = 0;
break;
default:
throw new ArgumentException("Invalid TextUnit.", nameof(unit));
}
return index;
}
private void MoveTo(int start, int end)
{
if (start < 0 || end < start)
throw new InvalidOperationException();
Start = start;
End = end;
}
public static bool IsEol(char c)
{
return c == '\r' || c == '\n';
}
}
}