Skip to content

Commit 466046e

Browse files
Avalonia: add screen reader support, keyboard navigation, and focus management (#4582)
Co-authored-by: Marc-André Moreau <mamoreau@devolutions.net>
1 parent 26d65ae commit 466046e

62 files changed

Lines changed: 985 additions & 200 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
<Style Selector="Border.settings-card-clickable:pointerover">
1414
<Setter Property="Background" Value="{DynamicResource SettingsCardHoverBackground}"/>
1515
</Style>
16+
<Style Selector="Border.settings-card-focused">
17+
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
18+
<Setter Property="BorderThickness" Value="2"/>
19+
</Style>
1620

1721
<!-- Setting warning subtext (amber, both themes) -->
1822
<Style Selector="TextBlock.setting-warning-text">
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Avalonia.Automation;
2+
using Avalonia.Threading;
3+
using UniGetUI.Core.Tools;
4+
5+
namespace UniGetUI.Avalonia.Infrastructure;
6+
7+
public sealed record AccessibilityAnnouncement(
8+
string Message,
9+
AutomationLiveSetting LiveSetting = AutomationLiveSetting.Polite);
10+
11+
public static class AccessibilityAnnouncementService
12+
{
13+
public static event EventHandler<AccessibilityAnnouncement>? AnnouncementRequested;
14+
15+
public static void Announce(
16+
string? message,
17+
AutomationLiveSetting liveSetting = AutomationLiveSetting.Polite)
18+
{
19+
if (string.IsNullOrWhiteSpace(message))
20+
return;
21+
22+
Dispatcher.UIThread.Post(() =>
23+
AnnouncementRequested?.Invoke(
24+
null,
25+
new AccessibilityAnnouncement(message, liveSetting)));
26+
}
27+
28+
public static void AnnounceToggle(string label, bool isEnabled)
29+
{
30+
Announce(CoreTools.Translate("{0} is now {1}", label,
31+
isEnabled ? CoreTools.Translate("Enabled") : CoreTools.Translate("Disabled")));
32+
}
33+
}

src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.ObjectModel;
22
using Avalonia;
3+
using Avalonia.Automation;
34
using Avalonia.Collections;
45
using Avalonia.Controls;
56
using Avalonia.Controls.ApplicationLifetimes;
@@ -113,6 +114,18 @@ private static void ShowOperationProgressNotification(AbstractOperation op)
113114
if (Settings.AreProgressNotificationsDisabled())
114115
return;
115116

117+
string title = op.Metadata.Title.Length > 0
118+
? op.Metadata.Title
119+
: CoreTools.Translate("Operation in progress");
120+
121+
string message = op.Metadata.Status.Length > 0
122+
? op.Metadata.Status
123+
: CoreTools.Translate("Please wait...");
124+
125+
AccessibilityAnnouncementService.Announce(
126+
$"{title}. {message}",
127+
AutomationLiveSetting.Polite);
128+
116129
if (WindowsAppNotificationBridge.ShowProgress(op))
117130
return;
118131

@@ -122,14 +135,6 @@ private static void ShowOperationProgressNotification(AbstractOperation op)
122135
if (TryGetMainWindow() is not { } mainWindow)
123136
return;
124137

125-
string title = op.Metadata.Title.Length > 0
126-
? op.Metadata.Title
127-
: CoreTools.Translate("Operation in progress");
128-
129-
string message = op.Metadata.Status.Length > 0
130-
? op.Metadata.Status
131-
: CoreTools.Translate("Please wait...");
132-
133138
mainWindow.ShowRuntimeNotification(
134139
title,
135140
message,
@@ -141,6 +146,18 @@ private static void ShowOperationSuccessNotification(AbstractOperation op)
141146
if (Settings.AreSuccessNotificationsDisabled())
142147
return;
143148

149+
string title = op.Metadata.SuccessTitle.Length > 0
150+
? op.Metadata.SuccessTitle
151+
: CoreTools.Translate("Success!");
152+
153+
string message = op.Metadata.SuccessMessage.Length > 0
154+
? op.Metadata.SuccessMessage
155+
: CoreTools.Translate("Success!");
156+
157+
AccessibilityAnnouncementService.Announce(
158+
$"{title}. {message}",
159+
AutomationLiveSetting.Polite);
160+
144161
WindowsAppNotificationBridge.RemoveProgress(op);
145162

146163
if (WindowsAppNotificationBridge.ShowSuccess(op))
@@ -152,14 +169,6 @@ private static void ShowOperationSuccessNotification(AbstractOperation op)
152169
if (TryGetMainWindow() is not { } mainWindow)
153170
return;
154171

155-
string title = op.Metadata.SuccessTitle.Length > 0
156-
? op.Metadata.SuccessTitle
157-
: CoreTools.Translate("Success!");
158-
159-
string message = op.Metadata.SuccessMessage.Length > 0
160-
? op.Metadata.SuccessMessage
161-
: CoreTools.Translate("Success!");
162-
163172
mainWindow.ShowRuntimeNotification(
164173
title,
165174
message,
@@ -171,6 +180,18 @@ private static void ShowOperationFailureNotification(AbstractOperation op)
171180
if (Settings.AreErrorNotificationsDisabled())
172181
return;
173182

183+
string title = op.Metadata.FailureTitle.Length > 0
184+
? op.Metadata.FailureTitle
185+
: CoreTools.Translate("Failed");
186+
187+
string message = op.Metadata.FailureMessage.Length > 0
188+
? op.Metadata.FailureMessage
189+
: CoreTools.Translate("An error occurred while processing this package");
190+
191+
AccessibilityAnnouncementService.Announce(
192+
$"{title}. {message}",
193+
AutomationLiveSetting.Assertive);
194+
174195
WindowsAppNotificationBridge.RemoveProgress(op);
175196

176197
if (WindowsAppNotificationBridge.ShowError(op))
@@ -182,14 +203,6 @@ private static void ShowOperationFailureNotification(AbstractOperation op)
182203
if (TryGetMainWindow() is not { } mainWindow)
183204
return;
184205

185-
string title = op.Metadata.FailureTitle.Length > 0
186-
? op.Metadata.FailureTitle
187-
: CoreTools.Translate("Failed");
188-
189-
string message = op.Metadata.FailureMessage.Length > 0
190-
? op.Metadata.FailureMessage
191-
: CoreTools.Translate("An error occurred while processing this package");
192-
193206
mainWindow.ShowRuntimeNotification(
194207
title,
195208
message,

src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ public partial class IgnoredPackageEntryViewModel : ObservableObject
139139
public string ManagerIconPath { get; }
140140
public string VersionDisplay { get; }
141141
public string NewVersion { get; }
142+
public string AutomationName { get; }
143+
public string RemoveAutomationName { get; }
142144

143145
private readonly string _ignoredId;
144146

@@ -154,6 +156,10 @@ public IgnoredPackageEntryViewModel(
154156
ManagerIconPath = managerIconPath;
155157
VersionDisplay = versionDisplay;
156158
NewVersion = newVersion;
159+
AutomationName = CoreTools.Translate("Package {name} from {manager}")
160+
.Replace("{name}", Name)
161+
.Replace("{manager}", Manager);
162+
RemoveAutomationName = CoreTools.Translate("Remove {0} from ignored updates", Name);
157163
}
158164

159165
[RelayCommand]

src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Specialized;
22
using Avalonia;
3+
using Avalonia.Automation;
34
using Avalonia.Collections;
45
using Avalonia.Controls;
56
using Avalonia.Controls.ApplicationLifetimes;
@@ -50,6 +51,12 @@ public partial class MainWindowViewModel : ViewModelBase
5051
public event EventHandler<bool>? CanGoBackChanged;
5152
public event EventHandler<PageType>? CurrentPageChanged;
5253

54+
[ObservableProperty]
55+
private string _announcementText = "";
56+
57+
[ObservableProperty]
58+
private AutomationLiveSetting _announcementLiveSetting = AutomationLiveSetting.Polite;
59+
5360
// ─── Operations panel ─────────────────────────────────────────────────────
5461
public AvaloniaList<OperationViewModel> Operations => AvaloniaOperationRegistry.OperationViewModels;
5562

@@ -113,6 +120,8 @@ private void OnPageViewModelPropertyChanged(object? sender, System.ComponentMode
113120

114121
public MainWindowViewModel()
115122
{
123+
AccessibilityAnnouncementService.AnnouncementRequested += OnAnnouncementRequested;
124+
116125
DiscoverPage = new DiscoverSoftwarePage();
117126
UpdatesPage = new SoftwareUpdatesPage();
118127
InstalledPage = new InstalledPackagesPage();
@@ -203,6 +212,15 @@ public MainWindowViewModel()
203212
LoadDefaultPage();
204213
}
205214

215+
private void OnAnnouncementRequested(object? _, AccessibilityAnnouncement announcement)
216+
{
217+
AnnouncementLiveSetting = announcement.LiveSetting;
218+
AnnouncementText = string.Empty;
219+
Dispatcher.UIThread.Post(
220+
() => AnnouncementText = announcement.Message,
221+
DispatcherPriority.Background);
222+
}
223+
206224
// ─── Navigation ──────────────────────────────────────────────────────────
207225
public void LoadDefaultPage()
208226
{
@@ -265,9 +283,14 @@ public void NavigateTo(PageType newPage_t, bool toHistory = true)
265283
if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return; }
266284
if (newPage_t is PageType.Quit) { (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); return; }
267285

268-
Sidebar.SelectNavButtonForPage(newPage_t);
286+
if (_currentPage == newPage_t)
287+
{
288+
// Re-focus the primary control even when we're already on the page
289+
(CurrentPageContent as AbstractPackagesPage)?.FocusPackageList();
290+
return;
291+
}
269292

270-
if (_currentPage == newPage_t) return;
293+
Sidebar.SelectNavButtonForPage(newPage_t);
271294

272295
var newPage = GetPageForType(newPage_t);
273296
var oldPage = CurrentPageContent as Control;
@@ -286,7 +309,6 @@ public void NavigateTo(PageType newPage_t, bool toHistory = true)
286309
CanGoBackChanged?.Invoke(this, true);
287310
}
288311

289-
(newPage as AbstractPackagesPage)?.FocusPackageList();
290312
(newPage as AbstractPackagesPage)?.FilterPackages();
291313
(newPage as IEnterLeaveListener)?.OnEnter();
292314

@@ -305,9 +327,29 @@ public void NavigateTo(PageType newPage_t, bool toHistory = true)
305327
GlobalSearchEnabled = false;
306328
}
307329

330+
// Focus after search state is restored so MegaQueryVisible is already correct
331+
(newPage as AbstractPackagesPage)?.FocusPackageList();
332+
333+
AccessibilityAnnouncementService.Announce(GetPageAnnouncement(newPage_t));
308334
CurrentPageChanged?.Invoke(this, newPage_t);
309335
}
310336

337+
private static string GetPageAnnouncement(PageType pageType) => pageType switch
338+
{
339+
PageType.Discover => CoreTools.Translate("Discover Packages"),
340+
PageType.Updates => CoreTools.Translate("Software Updates"),
341+
PageType.Installed => CoreTools.Translate("Installed Packages"),
342+
PageType.Bundles => CoreTools.Translate("Package Bundles"),
343+
PageType.Settings => CoreTools.Translate("Settings"),
344+
PageType.Managers => CoreTools.Translate("Package Managers"),
345+
PageType.OwnLog => CoreTools.Translate("UniGetUI Log"),
346+
PageType.ManagerLog => CoreTools.Translate("Package Manager logs"),
347+
PageType.OperationHistory => CoreTools.Translate("Operation history"),
348+
PageType.Help => CoreTools.Translate("Help"),
349+
PageType.ReleaseNotes => CoreTools.Translate("Release notes"),
350+
_ => CoreTools.Translate("UniGetUI"),
351+
};
352+
311353
public void NavigateBack()
312354
{
313355
if (CurrentPageContent is IInnerNavigationPage navPage && navPage.CanGoBack())

src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/GeneralViewModel.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
using System.IO;
2+
using Avalonia.Automation;
13
using CommunityToolkit.Mvvm.ComponentModel;
24
using CommunityToolkit.Mvvm.Input;
35
using global::Avalonia;
46
using global::Avalonia.Controls;
57
using global::Avalonia.Platform.Storage;
8+
using UniGetUI.Avalonia.Infrastructure;
69
using UniGetUI.Avalonia.ViewModels;
710
using UniGetUI.Avalonia.Views.DialogPages;
811
using UniGetUI.Avalonia.Views.Pages.SettingsPages;
@@ -37,6 +40,9 @@ private async Task ImportSettings(Visual? visual)
3740
var path = file.TryGetLocalPath();
3841
if (path is null) return;
3942
await Task.Run(() => CoreSettings.ImportFromFile_JSON(path));
43+
AccessibilityAnnouncementService.Announce(
44+
CoreTools.Translate("Settings imported from {0}", Path.GetFileName(path)),
45+
AutomationLiveSetting.Polite);
4046
OnRestartRequired();
4147
}
4248

@@ -52,7 +58,13 @@ private static async Task ExportSettings(Visual? visual)
5258
if (file is null) return;
5359
var path = file.TryGetLocalPath();
5460
if (path is null) return;
55-
try { await Task.Run(() => CoreSettings.ExportToFile_JSON(path)); }
61+
try
62+
{
63+
await Task.Run(() => CoreSettings.ExportToFile_JSON(path));
64+
AccessibilityAnnouncementService.Announce(
65+
CoreTools.Translate("Settings exported to {0}", Path.GetFileName(path)),
66+
AutomationLiveSetting.Polite);
67+
}
5668
catch (Exception ex) { Logger.Error(ex); }
5769
}
5870

@@ -61,6 +73,9 @@ private void ResetSettings(Visual? _)
6173
{
6274
try { CoreSettings.ResetSettings(); }
6375
catch (Exception ex) { Logger.Error(ex); }
76+
AccessibilityAnnouncementService.Announce(
77+
CoreTools.Translate("UniGetUI settings were reset"),
78+
AutomationLiveSetting.Assertive);
6479
OnRestartRequired();
6580
}
6681

src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.ComponentModel;
44
using System.Globalization;
55
using Avalonia;
6+
using Avalonia.Automation;
67
using Avalonia.Collections;
78
using Avalonia.Controls;
89
using Avalonia.Layout;
@@ -260,6 +261,7 @@ public Button AddToolbarButton(string svgName, string label, Action onClick, boo
260261
Content = content,
261262
};
262263
ToolTip.SetTip(btn, label);
264+
AutomationProperties.SetName(btn, label);
263265
btn.Click += (_, _) => onClick();
264266
ToolBarItems.Add(btn);
265267
return btn;
@@ -268,14 +270,16 @@ public Button AddToolbarButton(string svgName, string label, Action onClick, boo
268270
/// <summary>Adds a thin vertical separator to the toolbar.</summary>
269271
public void AddToolbarSeparator()
270272
{
271-
ToolBarItems.Add(new Separator
273+
var sep = new Separator
272274
{
273275
Width = 1,
274276
Height = 30,
275277
Margin = new Thickness(4, 4),
276278
Background = Application.Current?.FindResource("AppBorderBrush") as IBrush
277279
?? new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)),
278-
});
280+
};
281+
AutomationProperties.SetAccessibilityView(sep, AccessibilityView.Raw);
282+
ToolBarItems.Add(sep);
279283
}
280284

281285
public async Task ShowInfoDialog(Window owner, string title, string message)
@@ -759,11 +763,15 @@ public void ToggleSelectAll()
759763
{
760764
AllPackagesChecked = true;
761765
FilteredPackages.SelectAll();
766+
AccessibilityAnnouncementService.Announce(
767+
CoreTools.Translate("All packages selected"));
762768
}
763769
else
764770
{
765771
AllPackagesChecked = false;
766772
FilteredPackages.ClearSelection();
773+
AccessibilityAnnouncementService.Announce(
774+
CoreTools.Translate("Package selection cleared"));
767775
}
768776
}
769777

0 commit comments

Comments
 (0)