Skip to content

Commit b9814c7

Browse files
niksedkclaude
andcommitted
Expose accessible names on main editor time/duration/text controls (#11553)
Screen readers (NVDA/JAWS/Narrator/VoiceOver) read a control's UI Automation peer Name. The custom TimeCodeUpDown / SecondsUpDown controls focus an inner PART_TextBox whose name was empty, and several fields had no name at all, so the editor was largely unusable with a screen reader. - TimeCodeUpDown / SecondsUpDown: forward the control's AutomationProperties.Name to the inner PART_TextBox (the focused element) in OnApplyTemplate. - UiUtil.MakeNumericUpDown* factories: same forwarding for the built-in NumericUpDown, so every numeric field in the app is covered. - Main editor: name the Start time, End time (Hide time), Duration, Text and Layer controls with existing localized strings. - ColorWheelControl, WaveformPreviewControl, CuesPreviewControl: custom-drawn controls with no peer of their own now get a name. Adds a headless Avalonia test (Avalonia.Headless.XUnit) asserting the automation peer reports the expected name on the focused element for each control type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c020fa commit b9814c7

9 files changed

Lines changed: 180 additions & 0 deletions

File tree

src/ui/Controls/SecondsUpDown.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Avalonia;
2+
using Avalonia.Automation;
23
using Avalonia.Controls;
34
using Avalonia.Controls.Primitives;
45
using Avalonia.Controls.Templates;
@@ -142,6 +143,12 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
142143
if (_textBox != null)
143144
{
144145
_textBox.Text = FormatTime(Value);
146+
147+
// The inner text box is the element that actually receives keyboard focus,
148+
// so the accessible name set on this control must be forwarded to it for
149+
// screen readers to announce it (e.g. "Duration") instead of just the value.
150+
_textBox.Bind(AutomationProperties.NameProperty, this.GetObservable(AutomationProperties.NameProperty));
151+
145152
_textBox.KeyDown += OnTextBoxKeyDown;
146153
_textBox.LostFocus += (_, _) => ParseAndUpdate();
147154
_textBox.PointerWheelChanged += (_, args) =>

src/ui/Controls/TimeCodeUpDown.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Avalonia;
2+
using Avalonia.Automation;
23
using Avalonia.Controls;
34
using Avalonia.Controls.Primitives;
45
using Avalonia.Controls.Templates;
@@ -95,6 +96,11 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
9596
_textBuffer = FormatTime(Value);
9697
_textBox.Text = _textBuffer;
9798

99+
// The inner text box is the element that actually receives keyboard focus,
100+
// so the accessible name set on this control must be forwarded to it for
101+
// screen readers to announce it (e.g. "Start time") instead of just the value.
102+
_textBox.Bind(AutomationProperties.NameProperty, this.GetObservable(AutomationProperties.NameProperty));
103+
98104
_textBox.AddHandler(TextInputEvent, OnTextInput, RoutingStrategies.Tunnel);
99105
_textBox.AddHandler(KeyDownEvent, OnTextBoxKeyDown, RoutingStrategies.Tunnel);
100106
_textBox.GotFocus += OnTextBoxGotFocus;

src/ui/Features/Main/Layout/InitListViewAndEditBox.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using Avalonia;
3+
using Avalonia.Automation;
34
using Avalonia.Controls;
45
using Avalonia.Controls.Templates;
56
using Avalonia.Data;
@@ -1099,6 +1100,7 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel
10991100
{
11001101
DataContext = vm,
11011102
UseVideoOffset = true,
1103+
[AutomationProperties.NameProperty] = Se.Language.General.StartTime,
11021104
};
11031105
var startTimeBindingName = nameof(vm.SelectedSubtitle) + "." + (Se.Settings.Appearance.ShowUpDownEndTime
11041106
? nameof(SubtitleLineViewModel.StartTimeOnly)
@@ -1134,6 +1136,7 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel
11341136
var endCodeUpDown = new TimeCodeUpDown
11351137
{
11361138
DataContext = vm,
1139+
[AutomationProperties.NameProperty] = Se.Language.General.EndTime,
11371140
[!TimeCodeUpDown.ValueProperty] = new Binding($"{nameof(vm.SelectedSubtitle)}.{nameof(SubtitleLineViewModel.EndTime)}")
11381141
{
11391142
Mode = BindingMode.TwoWay,
@@ -1164,6 +1167,7 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel
11641167
var durationUpDown = new SecondsUpDown
11651168
{
11661169
DataContext = vm,
1170+
[AutomationProperties.NameProperty] = Se.Language.General.Duration,
11671171
[!SecondsUpDown.ValueProperty] = new Binding($"{nameof(vm.SelectedSubtitle)}.{nameof(SubtitleLineViewModel.Duration)}")
11681172
{
11691173
Mode = BindingMode.TwoWay,
@@ -1195,6 +1199,7 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel
11951199
}.WithBindVisible(vm, nameof(vm.ShowUpDownLabels));
11961200
panelLayer.Children.Add(labelLayer);
11971201
var upDownLayer = UiUtil.MakeNumericUpDownInt(int.MinValue, int.MaxValue, 0, double.NaN, vm, $"{nameof(vm.SelectedSubtitle)}.{nameof(SubtitleLineViewModel.Layer)}");
1202+
AutomationProperties.SetName(upDownLayer, Se.Language.General.Layer);
11981203
upDownLayer.HorizontalAlignment = HorizontalAlignment.Stretch;
11991204
if (!vm.ShowUpDownLabels && Se.Settings.Appearance.ShowHints)
12001205
{
@@ -1691,6 +1696,7 @@ private static Avalonia.Controls.Control MakeTextBox(MainViewModel vm)
16911696
FontWeight = Se.Settings.Appearance.SubtitleTextBoxFontBold ? FontWeight.Bold : FontWeight.Normal,
16921697
IsUndoEnabled = false,
16931698
ClearSelectionOnLostFocus = false,
1699+
[AutomationProperties.NameProperty] = Se.Language.General.Text,
16941700
};
16951701
if (Se.Settings.Appearance.SubtitleTextBoxCenterText)
16961702
{
@@ -1764,6 +1770,11 @@ private static TextEditor MakeTextEditor()
17641770
Padding = new Thickness(6, 4, 4, 4),
17651771
};
17661772

1773+
// Expose an accessible name for screen readers. The TextArea is the element
1774+
// that actually receives keyboard focus, so it needs the name too.
1775+
AutomationProperties.SetName(textEditor, Se.Language.General.Text);
1776+
AutomationProperties.SetName(textEditor.TextArea, Se.Language.General.Text);
1777+
17671778
// Add syntax highlighting transformer
17681779
textEditor.TextArea.TextView.LineTransformers.Add(new SubtitleSyntaxHighlighting());
17691780

src/ui/Features/Options/Settings/WaveformThemes/WaveformPreviewControl.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Avalonia;
2+
using Avalonia.Automation;
23
using Avalonia.Controls;
34
using Avalonia.Media;
5+
using Nikse.SubtitleEdit.Logic.Config;
46
using System;
57
using System.Collections.Generic;
68

@@ -34,6 +36,10 @@ public WaveformPreviewControl(WaveformThemesViewModel vm)
3436
_vm = vm;
3537
_peaks = GenerateSyntheticPeaks(SampleCount);
3638

39+
// Custom-drawn control with no automation peer of its own; give it a name so
40+
// screen readers announce it instead of an unlabeled element (issue #11553).
41+
AutomationProperties.SetName(this, Se.Language.General.Preview);
42+
3743
// Repaint whenever any color on the view-model changes
3844
_vm.PropertyChanged += (_, _) => InvalidateVisual();
3945
}

src/ui/Features/Shared/ColorPicker/ColorWheelControl.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using Avalonia;
2+
using Avalonia.Automation;
23
using Avalonia.Controls;
34
using Avalonia.Input;
45
using Avalonia.Media;
56
using Avalonia.Platform;
67
using Avalonia.Rendering.SceneGraph;
78
using Avalonia.Skia;
9+
using Nikse.SubtitleEdit.Logic.Config;
810
using SkiaSharp;
911
using System;
1012

@@ -39,6 +41,10 @@ public ColorWheelControl()
3941
Width = 200;
4042
Height = 200;
4143
ClipToBounds = true;
44+
45+
// Custom-drawn control with no automation peer of its own; give it a name
46+
// so screen readers can announce it (issue #11553).
47+
AutomationProperties.SetName(this, Se.Language.General.Color);
4248
}
4349

4450
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)

src/ui/Features/Tools/BeautifyTimeCodes/Profile/CuesPreviewControl.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using Avalonia;
2+
using Avalonia.Automation;
23
using Avalonia.Controls;
34
using Avalonia.Layout;
45
using Avalonia.Media;
56
using Nikse.SubtitleEdit.Core.Common;
7+
using Nikse.SubtitleEdit.Logic.Config;
68
using System;
79
using System.Globalization;
810

@@ -51,6 +53,10 @@ public CuesPreviewControl()
5153
Height = 70;
5254
HorizontalAlignment = HorizontalAlignment.Stretch;
5355
ClipToBounds = true;
56+
57+
// Custom-drawn control with no automation peer of its own; give it a name so
58+
// screen readers announce it instead of an unlabeled element (issue #11553).
59+
AutomationProperties.SetName(this, Se.Language.General.Preview);
5460
}
5561

5662
public override void Render(DrawingContext context)

src/ui/Logic/UiUtil.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using Avalonia;
2+
using Avalonia.Automation;
23
using Avalonia.Controls;
4+
using Avalonia.Controls.Primitives;
35
using Avalonia.Data;
46
using Avalonia.Data.Converters;
57
using Avalonia.Input;
@@ -1989,6 +1991,8 @@ public static NumericUpDown MakeNumericUpDownInt(int min, int max, int defaultVa
19891991
e.Handled = true;
19901992
});
19911993

1994+
ForwardAutomationNameToInnerTextBox(control);
1995+
19921996
return control;
19931997
}
19941998

@@ -2028,6 +2032,7 @@ public static NumericUpDown MakeNumericUpDownTwoDecimals(decimal min, decimal ma
20282032
}
20292033

20302034
MakeNumeriUpDownMouseWheelHandler(control);
2035+
ForwardAutomationNameToInnerTextBox(control);
20312036

20322037
return control;
20332038
}
@@ -2066,6 +2071,7 @@ public static NumericUpDown MakeNumericUpDownThreeDecimals(decimal min, decimal
20662071
}
20672072

20682073
MakeNumeriUpDownMouseWheelHandler(control);
2074+
ForwardAutomationNameToInnerTextBox(control);
20692075

20702076
return control;
20712077
}
@@ -2105,6 +2111,7 @@ public static NumericUpDown MakeNumericUpDownOneDecimal(decimal min, decimal max
21052111
}
21062112

21072113
MakeNumeriUpDownMouseWheelHandler(control);
2114+
ForwardAutomationNameToInnerTextBox(control);
21082115

21092116
return control;
21102117
}
@@ -2120,6 +2127,21 @@ private static void MakeNumeriUpDownMouseWheelHandler(NumericUpDown control)
21202127
});
21212128
}
21222129

2130+
/// <summary>
2131+
/// Forwards the accessible name set on a <see cref="NumericUpDown"/> to its inner
2132+
/// PART_TextBox. The text box is the element that actually receives keyboard focus,
2133+
/// so without this a screen reader would announce the focused field with no name
2134+
/// (issue #11553). Callers just set <c>AutomationProperties.Name</c> on the control.
2135+
/// </summary>
2136+
private static void ForwardAutomationNameToInnerTextBox(NumericUpDown control)
2137+
{
2138+
control.TemplateApplied += (_, e) =>
2139+
{
2140+
var textBox = e.NameScope.Find<TextBox>("PART_TextBox");
2141+
textBox?.Bind(AutomationProperties.NameProperty, control.GetObservable(AutomationProperties.NameProperty));
2142+
};
2143+
}
2144+
21232145
public static Label WithBindText(this Label control, object viewModel, string contentPropertyPath)
21242146
{
21252147
control.DataContext = viewModel;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using System.Linq;
2+
using Avalonia;
3+
using Avalonia.Automation;
4+
using Avalonia.Automation.Peers;
5+
using Avalonia.Controls;
6+
using Avalonia.Headless;
7+
using Avalonia.Headless.XUnit;
8+
using Avalonia.Themes.Fluent;
9+
using Avalonia.VisualTree;
10+
using Nikse.SubtitleEdit.Controls;
11+
using Nikse.SubtitleEdit.Logic;
12+
using UITests.Logic.Accessibility;
13+
14+
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
15+
16+
namespace UITests.Logic.Accessibility;
17+
18+
public class TestApp : Application
19+
{
20+
public override void Initialize()
21+
{
22+
// The custom up/down controls embed a ButtonSpinner + TextBox that need a
23+
// theme to resolve their templates when shown in a headless window.
24+
Styles.Add(new FluentTheme());
25+
}
26+
}
27+
28+
public static class TestAppBuilder
29+
{
30+
public static AppBuilder BuildAvaloniaApp() =>
31+
AppBuilder.Configure<TestApp>()
32+
.UseHeadless(new AvaloniaHeadlessPlatformOptions())
33+
.WithInterFont();
34+
}
35+
36+
/// <summary>
37+
/// Verifies the editor's time/duration controls expose an accessible Name to the
38+
/// platform automation layer (what NVDA / JAWS / Narrator / VoiceOver read aloud).
39+
/// The custom controls receive keyboard focus on an inner PART_TextBox, so the name
40+
/// set on the outer control must be forwarded to that text box (issue #11553).
41+
/// </summary>
42+
public class EditBoxAccessibilityNameTests
43+
{
44+
private static TextBox GetInnerTextBox(Control control)
45+
{
46+
// Force the control's template to apply so PART_TextBox exists and the
47+
// name-forwarding in OnApplyTemplate runs.
48+
var window = new Window { Content = control, Width = 320, Height = 120 };
49+
window.Show();
50+
control.ApplyTemplate();
51+
52+
return control.GetVisualDescendants().OfType<TextBox>().Single();
53+
}
54+
55+
private static string? AutomationName(Control control)
56+
=> ControlAutomationPeer.CreatePeerForElement(control)?.GetName();
57+
58+
[AvaloniaFact]
59+
public void StartTime_NameReachesAutomationPeer()
60+
{
61+
var startTime = new TimeCodeUpDown();
62+
AutomationProperties.SetName(startTime, "Start time");
63+
64+
var inner = GetInnerTextBox(startTime);
65+
66+
Assert.Equal("Start time", AutomationProperties.GetName(inner));
67+
Assert.Equal("Start time", AutomationName(inner));
68+
}
69+
70+
[AvaloniaFact]
71+
public void EndTime_NameReachesAutomationPeer()
72+
{
73+
var endTime = new TimeCodeUpDown();
74+
AutomationProperties.SetName(endTime, "Hide time");
75+
76+
var inner = GetInnerTextBox(endTime);
77+
78+
Assert.Equal("Hide time", AutomationName(inner));
79+
}
80+
81+
[AvaloniaFact]
82+
public void Duration_NameReachesAutomationPeer()
83+
{
84+
var duration = new SecondsUpDown();
85+
AutomationProperties.SetName(duration, "Duration");
86+
87+
var inner = GetInnerTextBox(duration);
88+
89+
Assert.Equal("Duration", AutomationName(inner));
90+
}
91+
92+
[AvaloniaFact]
93+
public void PlainTextBox_NameReachesAutomationPeer()
94+
{
95+
// The non-color-tag editor path is a plain TextBox; the name is set directly.
96+
var textBox = new TextBox();
97+
AutomationProperties.SetName(textBox, "Text");
98+
99+
Assert.Equal("Text", AutomationName(textBox));
100+
}
101+
102+
[AvaloniaFact]
103+
public void NumericUpDown_ForwardsAccessibleNameToInnerTextBox()
104+
{
105+
// Built via the real UiUtil factory, which wires up name forwarding to the
106+
// inner PART_TextBox (the focused element). Covers the Layer field and every
107+
// other numeric field in the app.
108+
var nud = UiUtil.MakeNumericUpDownInt(0, 100, 0, double.NaN, new object());
109+
AutomationProperties.SetName(nud, "Layer");
110+
111+
var inner = GetInnerTextBox(nud);
112+
113+
Assert.Equal("Layer", AutomationName(inner));
114+
}
115+
}

tests/UI/UITests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PrivateAssets>all</PrivateAssets>
1414
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1515
</PackageReference>
16+
<PackageReference Include="Avalonia.Headless.XUnit" Version="12.0.4" />
1617
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
1718
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
1819
<PrivateAssets>all</PrivateAssets>

0 commit comments

Comments
 (0)