committed by
Steven Kirk
14 changed files with 1061 additions and 32 deletions
@ -1,23 +1,131 @@ |
|||
using Avalonia.Automation.Provider; |
|||
using System; |
|||
using System.Collections; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Automation.Provider; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Reactive; |
|||
using Avalonia.Utilities; |
|||
using Avalonia.VisualTree; |
|||
|
|||
namespace Avalonia.Automation.Peers |
|||
{ |
|||
public class TextBoxAutomationPeer : ControlAutomationPeer, IValueProvider |
|||
public class TextBoxAutomationPeer : ControlAutomationPeer, ITextProvider, IValueProvider |
|||
{ |
|||
public TextBoxAutomationPeer(TextBox owner) |
|||
: base(owner) |
|||
{ |
|||
owner.GetObservable(TextBox.SelectionStartProperty).Subscribe(OnSelectionChanged); |
|||
owner.GetObservable(TextBox.SelectionEndProperty).Subscribe(OnSelectionChanged); |
|||
owner.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged); |
|||
} |
|||
|
|||
public int CaretIndex => Owner.CaretIndex; |
|||
public new TextBox Owner => (TextBox)base.Owner; |
|||
public bool IsReadOnly => Owner.IsReadOnly; |
|||
public int LineCount => Owner.Presenter?.TextLayout.TextLines.Count ?? 0; |
|||
public string? Value => Owner.Text; |
|||
public TextRange DocumentRange => new TextRange(0, Owner.Text?.Length ?? 0); |
|||
public string? PlaceholderText => Owner.Watermark; |
|||
public SupportedTextSelection SupportedTextSelection => SupportedTextSelection.Single; |
|||
|
|||
public event EventHandler? SelectedRangesChanged; |
|||
public event EventHandler? TextChanged; |
|||
|
|||
public IReadOnlyList<Rect> GetBounds(TextRange range) |
|||
{ |
|||
if (Owner.GetVisualRoot() is Visual root && |
|||
Owner.Presenter?.TransformToVisual(root) is Matrix m) |
|||
{ |
|||
var source = Owner.Presenter?.TextLayout.HitTestTextRange(range.Start, range.Length) |
|||
?? Array.Empty<Rect>(); |
|||
var result = new List<Rect>(); |
|||
foreach (var rect in source) |
|||
result.Add(rect.TransformToAABB(m)); |
|||
return result; |
|||
} |
|||
|
|||
return Array.Empty<Rect>(); |
|||
} |
|||
|
|||
public int GetLineForIndex(int index) |
|||
{ |
|||
return Owner.Presenter?.TextLayout.GetLineIndexFromCharacterIndex(index, false) ?? -1; |
|||
} |
|||
|
|||
public TextRange GetLineRange(int lineIndex) |
|||
{ |
|||
if (Owner.Presenter is null) |
|||
return TextRange.Empty; |
|||
|
|||
var line = Owner.Presenter.TextLayout.TextLines[lineIndex]; |
|||
return new TextRange(line.FirstTextSourceIndex, line.Length); |
|||
} |
|||
|
|||
public IReadOnlyList<TextRange> GetSelection() |
|||
{ |
|||
var range = TextRange.FromInclusiveStartEnd(Owner.SelectionStart, Owner.SelectionEnd); |
|||
return new[] { range }; |
|||
} |
|||
|
|||
public string GetText(TextRange range) |
|||
{ |
|||
var text = Owner.Text ?? string.Empty; |
|||
var start = MathUtilities.Clamp(range.Start, 0, text.Length); |
|||
var end = MathUtilities.Clamp(range.End, 0, text.Length); |
|||
return text.Substring(start, end - start); |
|||
} |
|||
|
|||
public IReadOnlyList<TextRange> GetVisibleRanges() |
|||
{ |
|||
// Not sure this is necessary, QT just returns the document range too.
|
|||
return new[] { DocumentRange }; |
|||
} |
|||
|
|||
public TextRange RangeFromPoint(Point p) |
|||
{ |
|||
if (Owner.Presenter is null) |
|||
return TextRange.Empty; |
|||
|
|||
var i = 0; |
|||
|
|||
if (Owner.GetVisualRoot() is Visual root && |
|||
root.TransformToVisual(Owner) is Matrix m) |
|||
{ |
|||
i = Owner.Presenter.TextLayout.HitTestPoint(p.Transform(m)).TextPosition; |
|||
} |
|||
|
|||
return new TextRange(i, 1); |
|||
} |
|||
|
|||
public void ScrollIntoView(TextRange range) |
|||
{ |
|||
if (Owner.Presenter is null || Owner.Scroll is null) |
|||
return; |
|||
|
|||
var rects = Owner.Presenter.TextLayout.HitTestTextRange(range.Start, range.Length); |
|||
var rect = default(Rect); |
|||
|
|||
foreach (var r in rects) |
|||
rect = rect.Union(r); |
|||
|
|||
Owner.Presenter.BringIntoView(rect); |
|||
} |
|||
|
|||
public void Select(TextRange range) |
|||
{ |
|||
Owner.SelectionStart = range.Start; |
|||
Owner.SelectionEnd = range.End; |
|||
} |
|||
|
|||
public void SetValue(string? value) => Owner.Text = value; |
|||
|
|||
protected override AutomationControlType GetAutomationControlTypeCore() |
|||
{ |
|||
return AutomationControlType.Edit; |
|||
} |
|||
|
|||
|
|||
private void OnSelectionChanged(int obj) => SelectedRangesChanged?.Invoke(this, EventArgs.Empty); |
|||
private void OnTextChanged(string? text) => TextChanged?.Invoke(this, EventArgs.Empty); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,139 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Automation.Peers; |
|||
|
|||
#nullable enable |
|||
|
|||
namespace Avalonia.Automation.Provider |
|||
{ |
|||
/// <summary>
|
|||
/// Describes the type of text selection supported by an element.
|
|||
/// </summary>
|
|||
public enum SupportedTextSelection |
|||
{ |
|||
/// <summary>
|
|||
/// The element does not support text selection.
|
|||
/// </summary>
|
|||
None, |
|||
|
|||
/// <summary>
|
|||
/// The element supports a single, continuous text selection.
|
|||
/// </summary>
|
|||
Single, |
|||
|
|||
/// <summary>
|
|||
/// The element supports multiple, disjoint text selections.
|
|||
/// </summary>
|
|||
Multiple, |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Exposes methods and properties to support access by a UI Automation client to controls
|
|||
/// that holds editable text.
|
|||
/// </summary>
|
|||
public interface ITextProvider |
|||
{ |
|||
/// <summary>
|
|||
/// Gets the current position of the caret, or -1 if there is no caret.
|
|||
/// </summary>
|
|||
int CaretIndex { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets a text range that encloses the main text of the document.
|
|||
/// </summary>
|
|||
TextRange DocumentRange { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets a value that indicates whether the text in the control is read-only.
|
|||
/// </summary>
|
|||
bool IsReadOnly { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the total number of lines in the main text of the document.
|
|||
/// </summary>
|
|||
int LineCount { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the placeholder text.
|
|||
/// </summary>
|
|||
string? PlaceholderText { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets a value that specifies the type of text selection that is supported by the control.
|
|||
/// </summary>
|
|||
SupportedTextSelection SupportedTextSelection { get; } |
|||
|
|||
/// <summary>
|
|||
/// Occurs when the control's selection changes.
|
|||
/// </summary>
|
|||
event EventHandler? SelectedRangesChanged; |
|||
|
|||
/// <summary>
|
|||
/// Occurs when the control's text changes.
|
|||
/// </summary>
|
|||
event EventHandler? TextChanged; |
|||
|
|||
/// <summary>
|
|||
/// Retrieves a collection of bounding rectangles for each fully or partially visible line
|
|||
/// of text in a text range.
|
|||
/// </summary>
|
|||
/// <param name="range">The text range.</param>
|
|||
/// <returns>A collection of <see cref="Rect"/>s in top-level coordinates.</returns>
|
|||
IReadOnlyList<Rect> GetBounds(TextRange range); |
|||
|
|||
/// <summary>
|
|||
/// Retrieves the line number for the line that contains the specified character index.
|
|||
/// </summary>
|
|||
/// <param name="index">The character index.</param>
|
|||
/// <returns>The line number, or -1 if the character index is invalid.</returns>
|
|||
int GetLineForIndex(int index); |
|||
|
|||
/// <summary>
|
|||
/// Retrieves a text range that encloses the specified line.
|
|||
/// </summary>
|
|||
/// <param name="lineIndex">The index of the line.</param>
|
|||
TextRange GetLineRange(int lineIndex); |
|||
|
|||
/// <summary>
|
|||
/// Retrieves a collection of text ranges that represents the currently selected text in a
|
|||
/// text-based control.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// If the control contains a text insertion point but no text is selected, the result
|
|||
/// should contain a degenerate (empty) text range at the position of the text insertion
|
|||
/// point.
|
|||
/// </remarks>
|
|||
IReadOnlyList<TextRange> GetSelection(); |
|||
|
|||
/// <summary>
|
|||
/// Retrieves the text for a specified range.
|
|||
/// </summary>
|
|||
/// <param name="range">The text range.</param>
|
|||
/// <returns>The text.</returns>
|
|||
string GetText(TextRange range); |
|||
|
|||
/// <summary>
|
|||
/// Retrieves a collection of disjoint text ranges from a text-based control where each
|
|||
/// text range represents a contiguous span of visible text.
|
|||
/// </summary>
|
|||
IReadOnlyList<TextRange> GetVisibleRanges(); |
|||
|
|||
/// <summary>
|
|||
/// Returns the degenerate (empty) text range nearest to the specified coordinates.
|
|||
/// </summary>
|
|||
/// <param name="p">The point in top-level coordinates.</param>
|
|||
TextRange RangeFromPoint(Point p); |
|||
|
|||
/// <summary>
|
|||
/// Scrolls the specified range of text into view.
|
|||
/// </summary>
|
|||
/// <param name="range">The text range.</param>
|
|||
void ScrollIntoView(TextRange range); |
|||
|
|||
/// <summary>
|
|||
/// Selects the specified range of text, replacing any previous selection.
|
|||
/// </summary>
|
|||
/// <param name="range"></param>
|
|||
void Select(TextRange range); |
|||
} |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
using System; |
|||
|
|||
namespace Avalonia.Automation.Provider |
|||
{ |
|||
/// <summary>
|
|||
/// Represents a range of text returned by an <see cref="ITextProvider"/>.
|
|||
/// </summary>
|
|||
public readonly struct TextRange : IEquatable<TextRange> |
|||
{ |
|||
/// <summary>
|
|||
/// Instantiates a new <see cref="TextRange"/> instance with the specified start index and
|
|||
/// length.
|
|||
/// </summary>
|
|||
/// <param name="start">The inclusive start index of the range.</param>
|
|||
/// <param name="length">The length of the range.</param>
|
|||
/// <exception cref="ArgumentException">
|
|||
/// <paramref name="length"/> was negative.
|
|||
/// </exception>
|
|||
public TextRange(int start, int length) |
|||
{ |
|||
if (length < 0) |
|||
throw new ArgumentException("Length may not be negative", nameof(length)); |
|||
Start = start; |
|||
Length = length; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the inclusive start index of the range.
|
|||
/// </summary>
|
|||
public int Start { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the exclusive end index of the range.
|
|||
/// </summary>
|
|||
public int End => Start + Length; |
|||
|
|||
/// <summary>
|
|||
/// Gets the length of the range.
|
|||
/// </summary>
|
|||
public int Length { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets an empty <see cref="TextRange"/>.
|
|||
/// </summary>
|
|||
public static TextRange Empty => new(0, 0); |
|||
|
|||
/// <summary>
|
|||
/// Creates a new <see cref="TextRange"/> from an inclusive start and end index.
|
|||
/// </summary>
|
|||
/// <param name="start">The inclusive start index of the range.</param>
|
|||
/// <param name="end">The inclusive end index of the range.</param>
|
|||
/// <returns></returns>
|
|||
public static TextRange FromInclusiveStartEnd(int start, int end) |
|||
{ |
|||
var s = Math.Min(start, end); |
|||
var e = Math.Max(start, end); |
|||
return new(s, e - s); |
|||
} |
|||
|
|||
public override bool Equals(object? obj) => obj is TextRange range && Equals(range); |
|||
public bool Equals(TextRange other) => Start == other.Start && Length == other.Length; |
|||
|
|||
public override int GetHashCode() |
|||
{ |
|||
var hashCode = -1730557556; |
|||
hashCode = hashCode * -1521134295 + Start.GetHashCode(); |
|||
hashCode = hashCode * -1521134295 + Length.GetHashCode(); |
|||
return hashCode; |
|||
} |
|||
|
|||
public static bool operator ==(TextRange left, TextRange right) => left.Equals(right); |
|||
public static bool operator !=(TextRange left, TextRange right) => !(left == right); |
|||
} |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Avalonia.Automation.Provider; |
|||
using UIA = Avalonia.Win32.Interop.Automation; |
|||
|
|||
#nullable enable |
|||
|
|||
namespace Avalonia.Win32.Automation |
|||
{ |
|||
internal partial class AutomationNode : UIA.ITextProvider |
|||
{ |
|||
public UIA.ITextRangeProvider DocumentRange |
|||
{ |
|||
get |
|||
{ |
|||
var range = InvokeSync<ITextProvider, TextRange>(x => x.DocumentRange); |
|||
return new AutomationTextRange(this, range); |
|||
} |
|||
} |
|||
|
|||
public UIA.SupportedTextSelection SupportedTextSelection |
|||
{ |
|||
get => (UIA.SupportedTextSelection)InvokeSync<ITextProvider, SupportedTextSelection>(x => x.SupportedTextSelection); |
|||
} |
|||
|
|||
public UIA.ITextRangeProvider[] GetVisibleRanges() => GetRanges(x => x.GetVisibleRanges()); |
|||
|
|||
public UIA.ITextRangeProvider? RangeFromChild(UIA.IRawElementProviderSimple childElement) => null; |
|||
|
|||
public UIA.ITextRangeProvider? RangeFromPoint(Point screenLocation) |
|||
{ |
|||
var p = ToClient(screenLocation); |
|||
var range = InvokeSync<ITextProvider, TextRange>(x => x.RangeFromPoint(screenLocation)); |
|||
return new AutomationTextRange(this, range); |
|||
} |
|||
|
|||
UIA.ITextRangeProvider[] UIA.ITextProvider.GetSelection() => GetRanges(x => x.GetSelection()); |
|||
|
|||
private UIA.ITextRangeProvider[] GetRanges(Func<ITextProvider, IReadOnlyList<TextRange>> selector) |
|||
{ |
|||
var source = InvokeSync(selector); |
|||
return source?.Select(x => new AutomationTextRange(this, x)).ToArray() ?? Array.Empty<AutomationTextRange>(); |
|||
} |
|||
|
|||
private void InitializeTextProvider() |
|||
{ |
|||
if (Peer is ITextProvider provider) |
|||
{ |
|||
provider.SelectedRangesChanged += PeerSelectedTextRangesChanged; |
|||
provider.TextChanged += PeerTextChanged; |
|||
} |
|||
} |
|||
|
|||
private void PeerSelectedTextRangesChanged(object? sender, EventArgs e) |
|||
{ |
|||
RaiseEvent(UIA.UiaEventId.Text_TextSelectionChanged); |
|||
} |
|||
|
|||
private void PeerTextChanged(object? sender, EventArgs e) |
|||
{ |
|||
RaiseEvent(UIA.UiaEventId.Text_TextChanged); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,474 @@ |
|||
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; |
|||
|
|||
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; private set; } |
|||
public int End { get; private set; } |
|||
public TextRange Range => TextRange.FromInclusiveStartEnd(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 < text.Length && 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'; |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue