v4.1: two screen-reader fixes — silent start-in-tray, announce on restore

- Starting straight into the tray (StartMinimised / --minimized) no longer plays the "minimise"
  cue; only a genuine user minimise does (the startup path passes playCue:false)
- Restoring the window from the tray now lands focus on a real named control on the active tab so
  NVDA announces it, instead of resting on the role-less QuietTabControl and surfacing silently
- Shared the focus-a-leaf-for-announcement helper between the main window and Preferences

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-13 15:59:59 +01:00
co-authored by Claude Opus 4.8
parent a408d2b56e
commit 5abbeefe29
7 changed files with 73 additions and 54 deletions
+11
View File
@@ -20,6 +20,17 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v4.1
A couple of small fixes for screen-reader users. When
RemSound starts straight into the notification area, it no
longer plays the "minimise" sound starting in the tray
isn't the same as you choosing to hide the window, so it
shouldn't sound the cue. And when you bring the window back
from the tray, RemSound now lands on a control on whichever
tab you'd left open, so your screen reader announces the
window instead of coming up silent.
RemSound v4.0
RemSound now has a sound for nearly everything you do.
+30
View File
@@ -27,6 +27,36 @@ internal static class WinEventNotifier
NotifyWinEvent(EVENT_OBJECT_FOCUS, control.Handle, OBJID_CLIENT, CHILDID_SELF);
}
}
/// <summary>Land NVDA on a named control so a freshly-shown window or dialog announces itself
/// instead of surfacing silently. Focuses the first focusable leaf inside <paramref name="page"/>
/// (falling back to <paramref name="fallback"/>), forcing a genuine focus CHANGE (ActiveControl=null
/// first) and re-firing the MSAA focus event. Used by the Preferences dialog on open and by the
/// main window when it's restored from the tray. The page sits inside a <see cref="QuietTabControl"/>
/// whose own accessible object is deliberately role-less and nameless, so focusing the tab control
/// itself would be silent — this finds a real leaf (the first interactive control on whatever tab
/// is showing) instead. Best run deferred (BeginInvoke), after the show/foreground settles.</summary>
public static void AnnounceByFocusingLeaf(ContainerControl form, Control? page, Control fallback)
{
var leaf = FirstFocusableLeaf(page) ?? fallback;
form.ActiveControl = null;
leaf.Focus();
if (leaf.IsHandleCreated) NotifyFocus(leaf);
}
/// <summary>The first visible, enabled, tab-stop control inside <paramref name="container"/>,
/// searched depth-first in child order (which matches the order controls were added). Returns a
/// real leaf (button / list / combo / checkbox) — never a layout panel or the role-less tab strip.</summary>
private static Control? FirstFocusableLeaf(Control? container)
{
if (container is null) return null;
foreach (Control c in container.Controls)
{
if (c is { CanSelect: true, TabStop: true, Visible: true, Enabled: true }) return c;
if (FirstFocusableLeaf(c) is { } nested) return nested;
}
return null;
}
}
/// <summary>
+13 -1
View File
@@ -606,6 +606,16 @@ public sealed class MainForm : Form
{
if (InvokeRequired) { BeginInvoke(new Action(RestoreFromTray)); return; }
trayController.Restore();
// Land focus on a real, named control on whichever tab is showing, so NVDA announces
// something when the window comes back from the tray. Without this, focus rests on the
// QuietTabControl (deliberately role-less / nameless) and a screen reader has nothing to
// read, so the window surfaces silently — the same issue the Preferences dialog had.
// Deferred so it runs after the show/foreground settles.
BeginInvoke(new Action(() =>
{
if (IsDisposed) return;
WinEventNotifier.AnnounceByFocusingLeaf(this, mainTabControl.SelectedTab, mainTabControl);
}));
}
catch { /* best-effort — surfacing the window is a convenience, not load-critical */ }
}
@@ -1265,7 +1275,9 @@ public sealed class MainForm : Form
startNextInstanceMinimized = false;
if (minimizeThisInstance)
{
BeginInvoke(() => trayController.Minimize());
// playCue:false — starting up in the tray (StartMinimised / --minimized) isn't the
// user choosing to minimise, so it must not sound the "minimise" cue.
BeginInvoke(() => trayController.Minimize(playCue: false));
}
// Kick off UPnP discovery if the user has the box ticked. Off by default; the
+8 -4
View File
@@ -227,13 +227,17 @@ internal sealed class MainFormTrayController : IDisposable
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
public void Minimize()
/// <summary>Hide the window to the tray. <paramref name="playCue"/> defaults true for a genuine
/// user-initiated minimise; the startup-minimise path passes false because that's not the user
/// choosing to hide the window — and the usual "was it visible?" guard can't tell the two apart,
/// since by the time the deferred startup minimise runs the window HAS been shown (Visible=true).</summary>
public void Minimize(bool playCue = true)
{
// Only sound the "hide" cue on a genuine shown -> hidden transition (not a startup-minimise
// before the window has ever been shown, nor a repeat call once already hidden).
// Only sound the "hide" cue on a genuine shown -> hidden transition the user asked for: not a
// startup-minimise (playCue:false), and not a repeat call once already hidden (wasVisible).
var wasVisible = owner.Visible;
owner.Hide();
if (wasVisible) (owner as MainForm)?.PlayWindowVisibilityCue(show: false);
if (wasVisible && playCue) (owner as MainForm)?.PlayWindowVisibilityCue(show: false);
// Refresh the tooltip BEFORE showing the icon so the shell's NIM_ADD call carries
// the current live state (peer count, send / receive routing, recording timer),
// not a stale "starting up" string set earlier. The shell tends to cache hover
+1 -20
View File
@@ -731,29 +731,10 @@ internal sealed class PreferencesDialog : Form
{
if (IsDisposed) return;
if (tabs.TabCount > 0) tabs.SelectedIndex = 0;
var leaf = FirstTabStopLeaf(tabs.SelectedTab) ?? (Control)tabs;
ActiveControl = null;
leaf.Focus();
if (leaf.IsHandleCreated) WinEventNotifier.NotifyFocus(leaf);
WinEventNotifier.AnnounceByFocusingLeaf(this, tabs.SelectedTab, tabs);
}));
}
/// <summary>The first visible, enabled, tab-stop control inside <paramref name="container"/>,
/// searched depth-first in child order (which matches the order controls were added to each tab).
/// Returns a real leaf the dialog can focus on open so NVDA has a named control to announce —
/// never a layout panel or the role-less tab strip.</summary>
private static Control? FirstTabStopLeaf(Control? container)
{
if (container is null) return null;
foreach (Control c in container.Controls)
{
if (c is { CanSelect: true, TabStop: true, Visible: true, Enabled: true })
return c;
if (FirstTabStopLeaf(c) is { } nested) return nested;
}
return null;
}
/// <summary>Wire up the Startup behaviour tab (moved here from StartupBehaviourDialog): load the
/// current state, populate the profile list, and persist each change immediately to AppConfig /
/// the Windows auto-start registry entry, exactly as the old dialog did.</summary>
+1 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>4.0</Version>
<Version>4.1</Version>
</PropertyGroup>
<ItemGroup>