Files
RemSound/src/RemSound.App/FormLayoutRows.cs
T
EdnunpandClaude Opus 4.8 a408d2b56e v4.0: full audio-cue system, cause-aware auto-tune, four-tab Preferences, install-side default sounds
Audio cues
- Cues for send/receive on-off, minimise/restore, checkbox tick/untick, and tab switch
- Soft keyboard clicks while typing, with a distinct passkey sound on password fields
- Per-cue "Choose sound" variant picker; "(none)" silences a cue; front-most missing-sound warning
- Send/receive cues take priority over the generic checkbox sound; programmatic ticks stay silent

Preferences
- Redesigned into four tabs (General, Audio cues, Startup behaviour, Update settings)
- Startup behaviour moved in from the Options menu
- NVDA now announces the dialog on open (focus a real named control, not the quiet tab control)

Auto-tune
- Cause-aware: tells device render-callback stalls (more buffer can't fix) apart from genuine
  network/buffer starvation, so it no longer pins latency high on chunky onboard cards
- Lowering the target eases the buffer down (glide) instead of trimming it, so no clicks while tuning

Sounds layout
- Shipped defaults moved out of the per-user folder into an install-side "default sounds" folder,
  so updates can refresh them; user customs are Browse-picked file paths and are left untouched
- Startup migration removes both legacy sound folders; verified from oldest (v1.0-v3.3) and v3.4 layouts

Quiet automated launches
- New --silent launch flag mutes all cue sounds and suppresses the startup dialogs (migration notice,
  update check, Realtek/mic/missing-sound warnings) so test launches never disturb the user
- run-tests / build-release / SelfTest repointed to the new "default sounds" layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:57:19 +01:00

127 lines
6.1 KiB
C#

namespace RemSound.App;
internal static class FormLayoutRows
{
public static void AddRow(TableLayoutPanel panel, int row, string labelText, Control control, Action<Control> focusControl)
{
var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = control };
label.Click += (_, _) => focusControl(control);
panel.Controls.Add(label, 0, row);
panel.Controls.Add(control, 1, row);
}
public static MnemonicLabel AddCheckedListRow(TableLayoutPanel panel, int row, string labelText, CheckedListBox list, Label statusLabel, Action<CheckedListBox> focusList)
{
// Restored the FlowLayoutPanel wrapping (matches the legacy/working RSound layout).
// Removing it caused NVDA to mis-pair labels with controls (off-by-one shift across
// the form), so the keyboard-shortcut announcements went to the wrong controls. The
// wrapping isn't ideal for label.ProcessMnemonic forwarding, but ProcessCmdKey handles
// the actual Alt+letter activation explicitly so we don't need to rely on that path.
// Returns the label so callers can update its text on mode changes (e.g. ASIO ⇄ WASAPI).
var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = list };
label.Click += (_, _) => focusList(list);
panel.Controls.Add(label, 0, row);
var container = new FlowLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.TopDown,
WrapContents = false,
TabStop = false,
};
container.Controls.Add(list);
container.Controls.Add(statusLabel);
panel.Controls.Add(container, 1, row);
return label;
}
}
/// <summary>
/// Form that exposes a delegate hook for ProcessCmdKey, so the dialog's Alt+letter activations
/// can be wired up from the closure-based dialog construction code without subclassing Form per
/// dialog type. Every Alt+key combo passes through the delegate first; if it returns true the
/// keystroke is consumed.
/// </summary>
internal sealed class CmdKeyForm : Form
{
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public Func<Keys, bool>? CmdKeyHandler { get; set; }
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (CmdKeyHandler is { } handler && handler(keyData)) return true;
return base.ProcessCmdKey(ref msg, keyData);
}
}
/// <summary>
/// TabControl subclass that suppresses the parent role announcement NVDA picks up from the
/// modern .NET 10 UIA exposure. The base class reports itself as a UIA Tab control type;
/// NVDA reads "tab control" before each tab's name. By returning AccessibleRole.None from
/// our custom AccessibleObject we hide the parent's role from screen readers entirely.
///
/// Andre's accessible-readout app reads cleanly because it's compiled against .NET Framework
/// 4.x, whose WinForms TabControl exposes less detail to MSAA/UIA. .NET 10 added more, and
/// Microsoft removed the opt-out — so this subclass is the only path.
///
/// Risk: dotnet/winforms#11831 was filed against .NET 8 reporting that overriding
/// CreateAccessibilityInstance throws InvalidOperationException. Status uncertain on .NET 10.
/// If we hit that exception at runtime, the fallback is to remove the override and accept
/// the announcement.
/// </summary>
internal sealed class QuietTabControl : TabControl
{
protected override AccessibleObject CreateAccessibilityInstance()
=> new QuietAcc(this);
protected override void OnSelectedIndexChanged(EventArgs e)
{
base.OnSelectedIndexChanged(e);
// Audible tab-switch cue, app-wide (every QuietTabControl gets it for free). Gated on
// ContainsFocus so a genuine user switch - arrow keys with the strip focused, or Ctrl+Tab
// with focus on a control inside the active page - clicks, while the programmatic
// SelectedIndex set done while a window is being built or its last tab restored (nothing
// here focused yet) stays silent.
if (ContainsFocus) TabSwitchSoundService.Play();
}
private sealed class QuietAcc : ControlAccessibleObject
{
public QuietAcc(Control owner) : base(owner) { }
// None hides the role from screen readers. NVDA falls through to the focused TabItem
// child whose role is "tab" — and reads only that. No "tab control" prefix.
public override AccessibleRole Role => AccessibleRole.None;
// Empty name so NVDA doesn't read a parent name either.
public override string? Name { get => string.Empty; set { } }
}
}
/// <summary>
/// Label that forwards its Alt+letter mnemonic activation to an explicit target control rather
/// than to "the next focusable control" (the default WinForms behaviour). Necessary because
/// SelectNextControl is unreliable across container boundaries — when a list is wrapped in a
/// FlowLayoutPanel for layout purposes, the default mnemonic walk skips past the panel and
/// focuses whatever comes after it in the parent panel.
/// </summary>
internal sealed class MnemonicLabel : Label
{
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public Control? MnemonicTarget { get; set; }
protected override bool ProcessMnemonic(char charCode)
{
if (!UseMnemonic || !IsMnemonic(charCode, Text)) return false;
if (MnemonicTarget is { } target && target.CanFocus)
{
target.Focus();
// Force NVDA to re-announce. Same load-bearing pattern the AccessibleCheckBox uses
// for state changes — the WinForms-built-in focus event isn't always picked up by
// the screen reader, especially when focus moves into a control wrapped in a
// FlowLayoutPanel via a synchronous Focus() call.
WinEventNotifier.NotifyFocus(target);
return true;
}
return false;
}
}