Skip to content

[Text input] Add ITextNavigation, a shared text navigation contract - #22178

Open
Gillibald wants to merge 2 commits into
mainfrom
stx/1-text-navigation
Open

Gillibald wants to merge 2 commits into
mainfrom
stx/1-text-navigation

Conversation

@Gillibald

@Gillibald Gillibald commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

Part of the structured text input work tracked in #22168.

Adds ITextNavigation, the layout-free core that IME and accessibility both need to walk a text document, and implements it over TextBox.

Today each consumer does that walk itself, with control-specific integer maths over a flattened string: the IME client, the automation peer and every platform bridge carry their own notion of where a word ends. This is the shared contract they can be written against instead.

  • ITextNavigation addresses a document by opaque ITextPointer positions with gravity, normalized ITextRange values, and boundary units (TextUnit), and raises a TextChange delta when the text changes. Positions and lengths are UTF-16 code units; a pointer stays valid across edits, which a bare offset cannot, and a pointer produced by a different navigation is rejected rather than silently reinterpreted in the wrong index space.
  • TextSegmentation (internal, in Avalonia.Base) supplies the unit boundaries: grapheme clusters for Character, UAX-29 for Word, mandatory line breaks for Line, and a heuristic for Sentence until real sentence segmentation lands. A word owns its trailing spaces and tabs, which is what UIA's "word plus trailing whitespace" and AT-SPI's GRANULARITY_WORD expect.
  • IAccessibleText extends the navigation with what a screen reader needs beyond reading: selection, geometry and hit testing, the visible range, scrolling, and formatting attributes over the run they are uniform across, reported through the TextAttribute and TextStyleId vocabularies.
  • TextBoxTextNavigation implements it for TextBox, backed by four new internal TextBox helpers that answer geometry queries from the presenter's TextLayout.

Everything new in the public surface is [Unstable].

This is the first slice of #22168, which #20890 carried as one unreviewable change. The accessibility providers (UIA, AT-SPI) and the IME contract land on top of it as separate PRs; nothing consumes TextBoxTextNavigation yet, so this PR changes no behavior on its own.

What is the updated/expected behavior with this PR?

No user-visible change. What a reviewer can check is the contract's own semantics: offsets clamp to the document, ranges normalize regardless of argument order, Character steps by grapheme cluster rather than code unit, Word follows UAX-29 and keeps contractions together, gravity decides which unit a boundary position belongs to, TextChanged reports the changed span and bumps the document version, and a foreign pointer throws on both the read and the mutation path.

Checklist

Breaking changes

None. The new types are additive and [Unstable]; TextSegmentation and TextBoxTextNavigation are internal.

Obsoletions / Deprecations

None.

Fixed issues

Part of #22168

🤖 Generated with Claude Code

Gillibald and others added 2 commits September 7, 2026 18:56
IME and accessibility both walk a text document by position, range and boundary
unit, and each does it today with control-specific integer maths over a
flattened string. This is the shared core they can be written against.

- Positions are opaque pointers with gravity, so one stays valid across an edit
  and a boundary position knows which unit it belongs to; a bare offset can do
  neither.
- Pointers carry provenance: one produced by a different navigation is rejected
  rather than reinterpreted in the wrong index space.
- Offsets and lengths are UTF-16 code units, and a contiguous change is
  reported as a delta rather than leaving consumers to diff the document.
- Any backing store can implement it - string, gap buffer, piece table, or a
  rope with an element tree.
- TextSegmentation supplies the unit boundaries: grapheme clusters for
  Character, UAX-29 for Word with a word owning its trailing spaces and tabs,
  mandatory breaks for Line, and a heuristic for Sentence until real sentence
  segmentation lands.

The surface is [Unstable] while the IME and accessibility consumers land on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An accessibility provider has to read and walk a control's text without
reaching into its layout, which nothing in Controls offered.

- TextBoxTextNavigation reads and walks the control's text, maps ranges to and
  from top-level coordinates, and reports changes.
- TextBox grows four internal helpers that answer those queries from the
  presenter's TextLayout: range rectangles, offset from a point, the visible
  range, and scroll-into-view.
- IAccessibleText extends the navigation with what a screen reader needs beyond
  reading: selection, geometry and hit testing, the visible range, scrolling,
  and formatting attributes over the run they are uniform across.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gillibald Gillibald changed the title Add ITextNavigation, a shared text navigation contract for IME and accessibility [Text input] Add ITextNavigation, a shared text navigation contract Sep 8, 2026
@Gillibald Gillibald added enhancement area-textprocessing api-needs-review The PR adds new public APIs that should be reviewed. labels Sep 8, 2026
@Gillibald

Copy link
Copy Markdown
Contributor Author

Public API diff for this branch against main, for review of the surface itself.

Generated with Microsoft.DotNet.ApiDiff.Tool (the version this repo pins in nukebuild/_build.csproj) over Release net8.0 builds of the two assemblies this PR touches. Everything is additive: no member is removed, and no existing signature changes.

Avalonia.Base

  namespace Avalonia.Input.TextInput
  {
+     public interface ITextNavigation
+     {
+         event System.EventHandler<Avalonia.Input.TextInput.TextChange?>? TextChanged;
+         int GetOffset(Avalonia.Input.TextInput.ITextPointer from, Avalonia.Input.TextInput.ITextPointer to);
+         Avalonia.Input.TextInput.ITextPointer GetPosition(Avalonia.Input.TextInput.ITextPointer origin, Avalonia.Input.TextInput.TextUnit unit, int count);
+         Avalonia.Input.TextInput.ITextPointer GetPosition(Avalonia.Input.TextInput.ITextPointer origin, int distance);
+         Avalonia.Input.TextInput.ITextRange GetRange(Avalonia.Input.TextInput.ITextPointer a, Avalonia.Input.TextInput.ITextPointer b);
+         Avalonia.Input.TextInput.ITextRange GetRangeEnclosing(Avalonia.Input.TextInput.ITextPointer position, Avalonia.Input.TextInput.TextUnit unit);
+         string GetText(Avalonia.Input.TextInput.ITextRange range);
+         Avalonia.Input.TextInput.ITextPointer DocumentEnd { get; }
+         Avalonia.Input.TextInput.ITextRange DocumentRange { get; }
+         Avalonia.Input.TextInput.ITextPointer DocumentStart { get; }
+         long DocumentVersion { get; }
+     }
+     public interface ITextPointer
+     {
+         Avalonia.Media.TextFormatting.LogicalDirection Gravity { get; }
+         int Offset { get; }
+     }
+     public interface ITextRange
+     {
+         Avalonia.Input.TextInput.ITextPointer End { get; }
+         bool IsEmpty { get; }
+         Avalonia.Input.TextInput.ITextPointer Start { get; }
+     }
+     public sealed class TextAttribute
+     {
+ public const Avalonia.Input.TextInput.TextAttribute Background = 5;
+         public const Avalonia.Input.TextInput.TextAttribute FontFamily = 0;
+         public const Avalonia.Input.TextInput.TextAttribute FontSize = 1;
+         public const Avalonia.Input.TextInput.TextAttribute FontStyle = 3;
+         public const Avalonia.Input.TextInput.TextAttribute FontWeight = 2;
+         public const Avalonia.Input.TextInput.TextAttribute Foreground = 4;
+         public const Avalonia.Input.TextInput.TextAttribute IsReadOnly = 6;
+         public const Avalonia.Input.TextInput.TextAttribute StyleId = 7;
+         public int value__;
+     }
+     public sealed class TextChange
+     {
+         public TextChange(Avalonia.Input.TextInput.ITextPointer Position, int OldLength, int NewLength);
+         public void Deconstruct(out Avalonia.Input.TextInput.ITextPointer Position, out int OldLength, out int NewLength);
+         public bool Equals(Avalonia.Input.TextInput.TextChange other);
+         public override bool Equals(object obj);
+         public override int GetHashCode();
+         public static bool operator ==(Avalonia.Input.TextInput.TextChange left, Avalonia.Input.TextInput.TextChange right);
+         public static bool operator !=(Avalonia.Input.TextInput.TextChange left, Avalonia.Input.TextInput.TextChange right);
+         public override string ToString();
+         public int NewLength { get; init; }
+         public int OldLength { get; init; }
+         public Avalonia.Input.TextInput.ITextPointer Position { get; init; }
+     }
+     public sealed class TextStyleId
+     {
+ public const Avalonia.Input.TextInput.TextStyleId BulletedList = 15;
+         public const Avalonia.Input.TextInput.TextStyleId Custom = 0;
+         public const Avalonia.Input.TextInput.TextStyleId Emphasis = 13;
+         public const Avalonia.Input.TextInput.TextStyleId Heading1 = 1;
+         public const Avalonia.Input.TextInput.TextStyleId Heading2 = 2;
+         public const Avalonia.Input.TextInput.TextStyleId Heading3 = 3;
+         public const Avalonia.Input.TextInput.TextStyleId Heading4 = 4;
+         public const Avalonia.Input.TextInput.TextStyleId Heading5 = 5;
+         public const Avalonia.Input.TextInput.TextStyleId Heading6 = 6;
+         public const Avalonia.Input.TextInput.TextStyleId Heading7 = 7;
+         public const Avalonia.Input.TextInput.TextStyleId Heading8 = 8;
+         public const Avalonia.Input.TextInput.TextStyleId Heading9 = 9;
+         public const Avalonia.Input.TextInput.TextStyleId Normal = 12;
+         public const Avalonia.Input.TextInput.TextStyleId NumberedList = 16;
+         public const Avalonia.Input.TextInput.TextStyleId Quote = 14;
+         public const Avalonia.Input.TextInput.TextStyleId Subtitle = 11;
+         public const Avalonia.Input.TextInput.TextStyleId Title = 10;
+         public int value__;
+     }
+     public sealed class TextUnit
+     {
+ public const Avalonia.Input.TextInput.TextUnit Character = 0;
+         public const Avalonia.Input.TextInput.TextUnit Document = 7;
+         public const Avalonia.Input.TextInput.TextUnit Format = 1;
+         public const Avalonia.Input.TextInput.TextUnit Line = 4;
+         public const Avalonia.Input.TextInput.TextUnit Page = 6;
+         public const Avalonia.Input.TextInput.TextUnit Paragraph = 5;
+         public const Avalonia.Input.TextInput.TextUnit Sentence = 3;
+         public int value__;
+         public const Avalonia.Input.TextInput.TextUnit Word = 2;
+     }
  }

Avalonia.Controls

  namespace Avalonia.Automation.Provider
  {
+     public interface IAccessibleText : Avalonia.Input.TextInput.ITextNavigation
+     {
+         string GetBlockSeparatedText(Avalonia.Input.TextInput.ITextRange range);
+         Avalonia.Rect[] GetBoundingRectangles(Avalonia.Input.TextInput.ITextRange range);
+         Avalonia.Input.TextInput.ITextPointer? GetPositionFromPoint(Avalonia.Point? point);
+         Avalonia.Input.TextInput.ITextRange GetSelection();
+         (System.Collections.Generic.IReadOnlyDictionary<Avalonia.Input.TextInput.TextAttribute, object>, Avalonia.Input.TextInput.ITextRange) GetTextAttributes(Avalonia.Input.TextInput.ITextPointer position);
+         Avalonia.Input.TextInput.ITextRange? GetVisibleRange();
+         void ScrollIntoView(Avalonia.Input.TextInput.ITextRange range);
+         void SetSelection(Avalonia.Input.TextInput.ITextRange range);
+     }
  }

Three notes on how the tool renders things, so the diff is not read as saying something it does not:

  • TextUnit, TextAttribute and TextStyleId are enums. The tool renders every enum as a sealed class with const members and a value__ field, which is the underlying representation rather than the declaration.
  • TextChange is a readonly record struct, which is why it shows a constructor, Deconstruct, equality members and init properties.
  • IAccessibleText.GetPositionFromPoint takes a plain Point; the Point? in the diff is the tool attaching the return type's nullability to the parameter.

Every new type carries [Unstable] (the tool is run with attribute output suppressed, so the attributes do not appear above). TextSegmentation, TextNavigationExtensions and TextBoxTextNavigation are internal and correctly absent.

🤖 Generated with Claude Code

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069610-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-needs-review The PR adds new public APIs that should be reviewed. area-textprocessing enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants