Two app lists in the main window: Currently active + Remembered

In WASAPI 'applications' send mode you now see two checkbox lists, mirroring the
peers lists:
- Currently active applications (Alt+8) — apps running right now.
- Remembered applications (Alt+9) — the global remembered-apps address book,
  including apps that aren't running (shown '(not running)').

Both drive ONE shared send set (selectedSendApps): tick an app in either list
and it's marked to send; the other list re-renders to match. Tick a remembered
app that's closed and, the instant it goes live, it appears ticked in the active
list AND capture begins from its start (the session-start watcher). Unticking in
either removes it. selectedSendApps is the source of truth (= per-profile
SelectedSendApplications on save); both lists render their checkboxes from it.

Layout: inserted the remembered list at row 11, shifted the send-input rows down.
Reconcile now rebuilds both lists; capture change-detection and profile
round-trip unchanged. Coverage audit now 42 controls, mnemonics + tab order
clean. Gate 37/37. Live two-list sync + capture wants on-machine testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-15 23:29:11 +01:00
co-authored by Claude Opus 4.8
parent e95fc36926
commit d9c78a5956
+100 -54
View File
@@ -105,9 +105,18 @@ public sealed class MainForm : Form
AutoSize = true,
Checked = true,
};
// "Currently active applications" — the apps running right now (like Discovered peers).
private readonly CheckedListBox sendAppsList = new() { CheckOnClick = true, Width = 430, Height = 90 };
private readonly Label sendAppsStatusLabel = new() { AutoSize = true, Text = "No application selected." };
private MnemonicLabel? sendAppsLabel;
// "Remembered applications" — the GLOBAL remembered-apps address book (like Remembered peers). Tick one
// here and it's marked to send; the instant it goes live it also appears (ticked) in the active list.
private readonly CheckedListBox rememberedAppsList = new() { CheckOnClick = true, Width = 430, Height = 90 };
private readonly Label rememberedAppsStatusLabel = new() { AutoSize = true, Text = "No remembered application." };
private MnemonicLabel? rememberedAppsLabel;
// The authoritative set of app process-names to send — ticked in EITHER list. Both lists render their
// checkboxes from this and toggling either updates it. Saved per-profile as SelectedSendApplications.
private readonly HashSet<string> selectedSendApps = new(StringComparer.OrdinalIgnoreCase);
private System.Windows.Forms.Timer? sendAppsReconcileTimer;
// Instant capture-on-start: fires the moment a ticked app opens an audio session, so we begin
// capturing it right at the start (the poll below is only a backstop). Live only while sending
@@ -957,8 +966,10 @@ public sealed class MainForm : Form
// Per-application send controls (issue #20). Listbox + list carry the "(Alt+N)" suffix like the
// other non-CheckBox controls; the master checkbox owns its own &-mnemonic via its Text.
sendModeList.AccessibleName = "How to send WASAPI audio (Alt+6)";
sendAppsList.AccessibleName = "Applications to send (Alt+8)";
sendAppsStatusLabel.AccessibleName = "Selected application status";
sendAppsList.AccessibleName = "Currently active applications (Alt+8)";
sendAppsStatusLabel.AccessibleName = "Active application status";
rememberedAppsList.AccessibleName = "Remembered applications (Alt+9)";
rememberedAppsStatusLabel.AccessibleName = "Remembered application status";
// Keyboard shortcuts / Minimise to tray / Save / Save as buttons retired 2026-05-08
// (now File menu items in BuildFileMenu).
asioDriverBox.AccessibleName = "ASIO driver (Alt+D)";
@@ -3483,7 +3494,7 @@ public sealed class MainForm : Form
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 2,
RowCount = 13,
RowCount = 14,
AutoScroll = true,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
@@ -3544,15 +3555,17 @@ public sealed class MainForm : Form
// Row 8 (devices mode): the classic WASAPI outputs-to-send loopback list.
sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "WASAPI audio outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl);
// Rows 9-10 (applications mode): the "Send all applications" master checkbox and the app list.
// Rows 9-11 (applications mode): the "Send all applications" master checkbox, then TWO app lists —
// currently-active (running now) and remembered (the global address book), mirroring the peers lists.
var sendAllAppsPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
sendAllAppsPanel.Controls.Add(sendAllApplicationsCheckbox);
panel.Controls.Add(sendAllAppsPanel, 1, 9);
sendAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 10, "Applications to send (Alt+&8)", sendAppsList, sendAppsStatusLabel, FocusListControl);
sendAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 10, "Currently active applications (Alt+&8)", sendAppsList, sendAppsStatusLabel, FocusListControl);
rememberedAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 11, "Remembered applications (Alt+&9)", rememberedAppsList, rememberedAppsStatusLabel, FocusListControl);
// Rows 11-12: the remaining send lists (unchanged, just shifted down two rows).
sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 11, "WASAPI audio inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl);
asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 12, "ASIO audio inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl);
// Rows 12-13: the remaining send lists (shifted down one row for the remembered-apps list above).
sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 12, "WASAPI audio inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl);
asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 13, "ASIO audio inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl);
WireSendModeControls();
@@ -3597,6 +3610,7 @@ public sealed class MainForm : Form
private void WireSendModeControls()
{
WireCheckedListAccessibility(sendAppsList, sendAppsStatusLabel, "application");
WireCheckedListAccessibility(rememberedAppsList, rememberedAppsStatusLabel, "remembered application");
sendModeList.SelectedIndexChanged += (_, _) =>
{
@@ -3615,18 +3629,21 @@ public sealed class MainForm : Form
ApplySendSources();
};
sendAppsList.ItemCheck += (_, args) =>
// Both app lists toggle the SAME send set: ticking an app in either the active or the remembered
// list adds it; unticking removes it. The other list re-renders to match, exactly like the peers
// lists. Defer to after the check state settles.
void WireAppList(CheckedListBox list)
{
if (suppressSendAppEvents) return;
// Defer to after the check state settles, then persist-dirty and re-apply the sources.
BeginInvoke(() =>
list.ItemCheck += (_, args) =>
{
if (suppressSendAppEvents) return;
MarkProfileDirty();
ApplySendSources();
RememberCheckedApps();
});
};
if (list.Items[args.Index] is not AudioAppChoice choice) return;
var nowChecked = args.NewValue == CheckState.Checked;
BeginInvoke(() => OnSendAppToggled(choice.ProcessName, nowChecked));
};
}
WireAppList(sendAppsList);
WireAppList(rememberedAppsList);
// Reconcile the app list on a slow timer so entries appear/disappear as apps open and close,
// without ever piling up (each pass releases every session object — see AudioAppEnumerator).
@@ -3680,6 +3697,8 @@ public sealed class MainForm : Form
var showAppList = appsMode && !sendAllApplicationsCheckbox.Checked;
if (sendAppsLabel is not null) sendAppsLabel.Visible = showAppList;
SetRowControlVisible(sendAppsList, showAppList);
if (rememberedAppsLabel is not null) rememberedAppsLabel.Visible = showAppList;
SetRowControlVisible(rememberedAppsList, showAppList);
// Only the specific-apps case needs the reconcile poll + the instant session-start watcher (in
// "send all applications" mode we just loopback the whole default device, no per-app tracking).
@@ -3696,48 +3715,76 @@ public sealed class MainForm : Form
}
}
/// <summary>Refreshes <see cref="sendAppsList"/> from the current audio apps, preserving the user's
/// ticks by process NAME and keeping any ticked-but-not-currently-running apps in the list (so a
/// selection survives an app closing and reopening). Tagged items carry the process name; the
/// display shows the friendly name and a "(not running)" hint when the app isn't live.</summary>
/// <summary>A send-app checkbox was toggled in EITHER list: update the one shared send set, remember the
/// app globally when ticked, re-apply the capture, and re-render both lists so they stay in lock-step
/// (tick it in Remembered and it shows ticked in Active the instant it's live). Mirrors the peers lists.</summary>
private void OnSendAppToggled(string name, bool nowChecked)
{
if (suppressSendAppEvents || string.IsNullOrWhiteSpace(name)) return;
if (nowChecked) selectedSendApps.Add(name); else selectedSendApps.Remove(name);
if (nowChecked)
{
var remembered = settings.LoadRememberedApplications().ToList();
if (!remembered.Any(e => string.Equals(e, name, StringComparison.OrdinalIgnoreCase)))
{
remembered.Add(name);
settings.SaveRememberedApplications(remembered);
}
}
MarkProfileDirty();
ApplySendSources();
ReconcileSendAppsList(); // re-render both lists to reflect the new shared selection
}
/// <summary>Refreshes BOTH send-app lists from the current state: the "currently active" list = apps
/// running right now; the "remembered" list = the global remembered-apps address book (plus anything in
/// the active selection). Every item is ticked iff it's in <see cref="selectedSendApps"/>, so the two
/// lists stay in lock-step. A "(not running)" hint marks a remembered app that isn't live.</summary>
private void ReconcileSendAppsList()
{
if (!ProcessLoopbackCapture.IsSupported) return;
// Names the user currently has ticked (keep them even if the app has since closed).
var tickedNames = new HashSet<string>(
sendAppsList.CheckedItems.OfType<AudioAppChoice>().Select(c => c.ProcessName),
StringComparer.OrdinalIgnoreCase);
var running = AudioAppEnumerator.Snapshot();
var runningNames = new HashSet<string>(running.Select(a => a.ProcessName), StringComparer.OrdinalIgnoreCase);
// Build the merged set: every running app, plus any ticked app that isn't running right now.
var choices = new List<AudioAppChoice>();
foreach (var a in running)
choices.Add(new AudioAppChoice(a.ProcessName, a.DisplayName, running: true));
foreach (var name in tickedNames)
if (!runningNames.Contains(name))
choices.Add(new AudioAppChoice(name, name, running: false));
choices.Sort((x, y) => string.Compare(x.ToString(), y.ToString(), StringComparison.CurrentCultureIgnoreCase));
// Remembered = the global address book, unioned with the current selection (so a just-ticked app
// always appears) — sorted by display name.
var remembered = settings.LoadRememberedApplications()
.Concat(selectedSendApps)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase)
.ToList();
suppressSendAppEvents = true;
try
{
sendAppsList.BeginUpdate();
sendAppsList.Items.Clear();
foreach (var c in choices)
FillAppList(sendAppsList, running.Select(a => new AudioAppChoice(a.ProcessName, a.DisplayName, running: true)));
FillAppList(rememberedAppsList, remembered.Select(name =>
{
var index = sendAppsList.Items.Add(c);
if (tickedNames.Contains(c.ProcessName)) sendAppsList.SetItemChecked(index, true);
}
sendAppsList.EndUpdate();
var live = running.FirstOrDefault(a => string.Equals(a.ProcessName, name, StringComparison.OrdinalIgnoreCase));
return new AudioAppChoice(name, live?.DisplayName ?? name, running: runningNames.Contains(name));
}));
}
finally
{
suppressSendAppEvents = false;
}
UpdateCheckedListStatus(sendAppsList, sendAppsStatusLabel, "application");
UpdateCheckedListStatus(rememberedAppsList, rememberedAppsStatusLabel, "remembered application");
}
/// <summary>Rebuild a CheckedListBox from a set of app choices, ticking each one that's in the shared
/// send set. Caller must hold <see cref="suppressSendAppEvents"/>.</summary>
private void FillAppList(CheckedListBox list, IEnumerable<AudioAppChoice> choices)
{
list.BeginUpdate();
list.Items.Clear();
foreach (var c in choices)
{
var i = list.Items.Add(c);
if (selectedSendApps.Contains(c.ProcessName)) list.SetItemChecked(i, true);
}
list.EndUpdate();
}
/// <summary>Re-resolve the ticked apps' current process ids and, if they changed (an app opened or
@@ -3800,13 +3847,12 @@ public sealed class MainForm : Form
sendModeList.SelectedIndex = wantApps ? SendModeApplicationsIndex : SendModeDevicesIndex;
sendAllApplicationsCheckbox.Checked = p.SendAllApplications;
sendAppsList.Items.Clear();
foreach (var name in (p.SelectedSendApplications ?? new()).Distinct(StringComparer.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(name)) continue;
var idx = sendAppsList.Items.Add(new AudioAppChoice(name, name, running: false));
sendAppsList.SetItemChecked(idx, true);
}
// The profile's active apps become the shared send set; ReconcileSendAppsList (below) then
// renders both lists (active + remembered) from it.
selectedSendApps.Clear();
foreach (var name in (p.SelectedSendApplications ?? new())
.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
selectedSendApps.Add(name);
}
finally
{
@@ -3833,8 +3879,7 @@ public sealed class MainForm : Form
}
/// <summary>The process names the user has ticked in the app list (lower-case, no extension).</summary>
private List<string> CheckedSendApplicationNames() =>
sendAppsList.CheckedItems.OfType<AudioAppChoice>().Select(c => c.ProcessName).Distinct().ToList();
private List<string> CheckedSendApplicationNames() => selectedSendApps.ToList();
/// <summary>One row in the "Applications to send" list. Identity is the process NAME; the display
/// adds a "(not running)" hint for a remembered-but-closed app.</summary>
@@ -5466,9 +5511,10 @@ public sealed class MainForm : Form
sendModeList.TabIndex = 6; // how-to-send chooser, right after "Send my audio"
sendOutputDevicesList.TabIndex = 7; // devices mode
sendAllApplicationsCheckbox.TabIndex = 8; // applications mode
sendAppsList.TabIndex = 9; // applications mode
sendInputDevicesList.TabIndex = 10;
asioSendDevicesList.TabIndex = 11;
sendAppsList.TabIndex = 9; // applications mode — currently active
rememberedAppsList.TabIndex = 10; // applications mode — remembered
sendInputDevicesList.TabIndex = 11;
asioSendDevicesList.TabIndex = 12;
// Profiles & preferences tab retired 2026-05-08 — the controls that used to live
// there have moved to the File menu (Open/Save/Save as/Rename/etc.) and the
// Preferences dialog (Mute cues / Accept remote vol / Startup behaviour).
@@ -5753,7 +5799,7 @@ public sealed class MainForm : Form
/// <summary>True when applications mode will actually send something: "send all applications" is on,
/// or at least one specific app is ticked.</summary>
private bool HasAppModeSend() =>
AppsModeActive() && (sendAllApplicationsCheckbox.Checked || sendAppsList.CheckedItems.Count > 0);
AppsModeActive() && (sendAllApplicationsCheckbox.Checked || selectedSendApps.Count > 0);
private bool HasCheckedSendDevice() =>
(!AppsModeActive() && sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>().Any(c => c.DeviceId is not null))