Skip to content

Add Atc.Wpf.Hardware pickers and upgrade Monitor/Terminal viewers - #191

Merged
davidkallesen merged 54 commits into
mainfrom
feature/pickers
May 6, 2026
Merged

Add Atc.Wpf.Hardware pickers and upgrade Monitor/Terminal viewers#191
davidkallesen merged 54 commits into
mainfrom
feature/pickers

Conversation

@davidkallesen

Copy link
Copy Markdown
Contributor

Summary

  • Add new Atc.Wpf.Hardware library with 12 device-picker families
  • Upgrade ApplicationMonitorView and TerminalViewer to enterprise-grade
  • Move TimeZonePicker from Hardware to Forms for better domain fit
  • Fix audio buffer access, picker layout shifts, and binding issues

Changes

✨ Features

  • Scaffold Atc.Wpf.Hardware project with shared device-state plumbing
  • Add SerialPort, UsbPort, and UsbCamera picker families
  • Add AudioInput / AudioOutput pickers with live preview and tester
  • Add Drive, Bluetooth, Process, Window picker families
  • Add NetworkAdapter, Printer, Display picker families
  • Add UsbCameraPicker live preview with PreferredFormat support
  • Add IDeviceWatcherHost abstraction for testable device watching
  • Upgrade ApplicationMonitorView: virtualization, batching, MEL, export
  • Upgrade TerminalViewer: search, ANSI parser, export, keyboard model
  • Integrate all pickers into the sample app with side-panel demos
  • Add XUnit coverage for pickers, models, services, value converters
  • Add FlaUI auto-scroll E2E tests for both component viewers

🐛 Fixes

  • Bridge IMemoryBufferByteAccess via WinRT.CastExtensions
  • Surface picker InUse state via ValidationText to prevent shift
  • Drop DataContext = this from LabelColorPicker (binding inheritance)
  • Apply auto-scroll guard to ApplicationMonitorView
  • Play AudioOutputPicker test tone via MediaPlayer + in-memory WAV
  • Use explicit stereo encoding for AudioFrame nodes
  • Translate UsbDeviceClassFilter to a real AQS query
  • Use Gray10 for live preview backgrounds in dark mode

♻️ Refactoring

  • Move TimeZonePicker family from Hardware to Forms
  • Abstract DeviceWatcher behind IDeviceWatcherHost for testability

📝 Documentation

  • Add Hardware project @Readme, overview, and changelog
  • Update sample app docs to include the Wpf.Hardware section
  • Add Hardware pickers roadmap; mark service-test deferrals as done

davidkallesen and others added 30 commits May 5, 2026 14:05
Plan for Atc.Wpf.Hardware library introducing SerialPortPicker, UsbPortPicker
and UsbCameraPicker plus labeled variants. Documents the device-state UX
design (hot-plug detection via DeviceWatcher, in-use probing, disconnect
handling), TFM decision (net10.0-windows10.0.19041.0 for WinRT
DeviceWatcher), localization scope (en-US/da-DK/de-DE), and the open
questions resolved during design (MediaFoundation via WinRT for camera
enumeration; auto-refresh default-on; in-use active probe opt-in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Atc.Wpf.Hardware library (TFM net10.0-windows10.0.19041.0) hosting the
infrastructure used by the picker controls in subsequent commits:

  - DeviceState enum + IDeviceInfo abstraction
  - DeviceWatcherHost wrapping Windows.Devices.Enumeration.DeviceWatcher
    with UI-thread marshalling and DeviceArrived/Updated/Removed event args
  - JustConnectedTimer scheduling JustConnected → Available transitions
  - UsbIdParser extracting VID/PID from device IDs (covered by tests)
  - DeviceStateToBrushConverter / DeviceStateToTextConverter for status
    visuals (green/amber/red dot + localised state text)
  - Resources/Miscellaneous.resx + Resources/Validations.resx triplets
    (en-US invariant + da-DK + de-DE)
  - Test project Atc.Wpf.Hardware.Tests with UsbIdParserTests
  - InternalsVisibleTo(\"Atc.Wpf.Hardware\") added to Atc.Wpf.Controls so
    the library can reuse ControlHelper from Forms-style label wrappers

Picker and labeled controls follow in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the first hardware picker built on the device-state plumbing introduced
in the previous commit:

  - SerialPortInfo POCO (DeviceId, PortName, FriendlyName, VendorId,
    ProductId, observable State)
  - ISerialPortService + SerialPortService — DeviceWatcher with
    SerialDevice.GetDeviceSelector() AQS, requests
    System.DeviceInterface.Serial.PortName so COM names land in the dropdown;
    ProbeInUseAsync exposes the opt-in active probe via
    SerialDevice.FromIdAsync
  - SerialPortPicker UserControl — ComboBox + refresh button + inline state
    warning row; default ItemTemplate shows colour-coded status dot, port
    name and localised state text; raises ValueChanged, DeviceLost and
    DeviceReconnected as routed events; honours AutoRefreshOnDeviceChange,
    DetectInUseState, ClearValueOnDisconnect, AutoRebindOnReconnect and
    AutoSelectFirstAvailable DPs with the defaults from the roadmap
  - SerialPortPickerAutomationPeer (IValueProvider) for UI-automation tests
  - LabelSerialPortPicker + ILabelSerialPortPicker abstraction wrapping the
    picker in a LabelContent with mandatory and disconnected-device
    validation, LostFocusValid / LostFocusInvalid events
  - DeviceStateChangedRoutedEventArgs (used by DeviceStateChanged event in a
    later commit)
  - Per-control _Readme.md files next to picker and label
  - GlobalUsings + AssemblyInfo updated to expose Pickers, Abstractions,
    Services and Pickers.Internal namespaces

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the second hardware picker, sibling to SerialPortPicker and built on
the same device-state plumbing:

  - UsbDeviceInfo POCO (DeviceId, FriendlyName, VendorId, ProductId,
    PnpClass, InterfaceEnabled, observable State)
  - UsbDeviceClassFilter flags enum (None / Hid / Imaging / Audio / Printer
    / MassStorage / Communication) — reserved for future AQS translation;
    currently surfaced as a DP for forward compatibility
  - IUsbDeviceService + UsbDeviceService — DeviceWatcher with a USB device
    interface AQS (\"System.Devices.InterfaceClassGuid:={a5dcbf10-...}\"),
    maps DeviceInformation.IsEnabled=false to DeviceState.InUse so the
    picker flags busy devices automatically
  - UsbPortPicker UserControl + UsbPortPickerAutomationPeer mirroring the
    SerialPortPicker layout and DPs (ClassFilter exposed as a DP for the
    future filter work)
  - LabelUsbPortPicker + ILabelUsbPortPicker abstraction with mandatory and
    disconnected-device validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the third hardware picker, completing the trio. Designed for selecting
video-capture devices (webcams, capture cards) so the chosen DeviceId can
be passed directly to MediaCaptureInitializationSettings.VideoDeviceId for
downstream MediaCapture use:

  - UsbCameraInfo POCO (DeviceId, FriendlyName, Panel, IsEnabled,
    observable State) plus CameraPanel enum (Unknown / Front / Back / Top
    / Bottom / Left / Right / External)
  - IUsbCameraService + UsbCameraService — DeviceWatcher with the
    DeviceClass.VideoCapture AQS; reads DeviceInformation.EnclosureLocation
    for built-in panel orientation (when the OS provides it) and uses
    DeviceInformation.IsEnabled to flag disabled cameras as InUse
  - UsbCameraPicker UserControl + UsbCameraPickerAutomationPeer mirroring
    the SerialPort/UsbPort layout
  - LabelUsbCameraPicker + ILabelUsbCameraPicker abstraction with mandatory
    and disconnected-device validation

Live preview, format enumeration (resolution/FPS), and an active \"Test
camera\" probe are deferred to a later v2 iteration — opening MediaCapture
is heavyweight and only worth doing on user request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the new Atc.Wpf.Hardware library into the sample explorer:

  - New SamplesWpfHardware/ folder under the sample app with per-picker
    demo views (SerialPortPickerView / UsbPortPickerView /
    UsbCameraPickerView) plus their labeled siblings
  - SamplesWpfHardwareTreeView with three nodes: Demos, Pickers,
    LabelControls
  - New Hot-plug & In-Use dedicated demo view (SamplesWpfHardware/Demos)
    that hosts all three pickers next to a live event log streaming every
    routed event (ValueChanged / DeviceLost / DeviceReconnected /
    DeviceStateChanged) — the visible payoff for the device-state UX
  - MainWindow.xaml + .cs updated to add the TabWpfHardware TabItem with
    Badge, register the tree view in the stack, and map it to the
    tab/badge/visibility dictionaries
  - Sample app and UiTests TFM bumped from net10.0-windows to
    net10.0-windows10.0.19041.0 to satisfy the project reference to
    Atc.Wpf.Hardware
  - Atc.Wpf.Sample.GlobalUsings.cs gains the
    Atc.Wpf.Hardware.Models / .Pickers namespaces

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final cross-cutting documentation pass for the Atc.Wpf.Hardware library:

  - docs/Hardware/@Readme.md — category-level overview covering the three
    picker controls, device-state UX (state colour map, selected-Value
    behaviour through disconnect / reconnect / in-use), the common DPs
    surface, localization scope and the TFM requirement
  - CLAUDE.md project overview gains an Atc.Wpf.Hardware bullet and a
    project-tree entry; sample-app section lists the new Wpf.Hardware
    category and TreeView file
  - docs/sample-app.md gains the Wpf.Hardware row in the category table
  - CHANGELOG.md \"[Unreleased] / Added\" entry summarising the library,
    the device-state UX defaults (preserve user intent on disconnect,
    auto-rebind on reconnect, opt-in active probe) and the TFM choice

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ategory count

The Wpf.Hardware row was added to the Categories table when the picker
family landed but the surrounding documentation (the ASCII layout
diagram, the XAML structure example, and the "8 total" heading) was
not updated. Sync them now so the doc reflects the nine-tab reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UsbDeviceClassFilter was previously stored on the service but never
applied — UsbPortPicker enumerated every USB device interface and any
class restriction had to be done client-side. Introduce
UsbDeviceClassFilterResolver, which maps each flag to its device
interface class GUID (HID, Imaging, Audio, Printer, MassStorage,
ComPort) and OR-joins the selected clauses into a single AQS string.

UsbDeviceService now rebuilds its DeviceWatcher when ClassFilter
changes (preserving the started state), so a binding-driven filter
change in the picker actually narrows the enumeration at the OS level
instead of requiring the consumer to filter the list themselves.

Roadmap §2.1 promoted from in-progress to done; nine resolver tests
cover the None / single-flag / multi-flag / all-flags cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Microphone and speaker pickers built on the same DeviceWatcherHost
plumbing as the existing serial / USB / camera pickers. Uses
DeviceClass.AudioCapture and DeviceClass.AudioRender so hot-plug,
disconnect and in-use are reflected live without the consumer touching
WM_DEVICECHANGE or polling.

A single AudioDeviceService takes the AudioDeviceKind as a constructor
argument; both pickers expose the full state-aware DP / routed-event
surface (Value, AutoRefreshOnDeviceChange, ClearValueOnDisconnect,
AutoRebindOnReconnect, AutoSelectFirstAvailable, ItemTemplate,
ValueChanged, DeviceLost, DeviceReconnected, DeviceStateChanged), and
ship Label* wrappers with mandatory + disconnected-device validation.

The system default endpoint is rendered with a star in the dropdown
via AudioDeviceInfo.IsDefault. Active in-use probing is intentionally
not performed for audio (would require opening the endpoint, which is
intrusive); passive detection still maps a disabled interface to
DeviceState.InUse.

Strings localised for en-US / da-DK / de-DE. AudioDeviceInfoTests
cover the POCO / INPC / ToString contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the four Audio*Picker sample views (input/output picker pages plus
their Label* wrappers) with demo ViewModels following the same
PropertyDisplay layout as the existing UsbCamera sample, and wire the
five new entries into SamplesWpfHardwareTreeView. Hardware @Readme,
top-level CLAUDE.md, the roadmap §6 candidate row, and CHANGELOG are
updated to reflect five pickers (Serial / USB / Camera / AudioIn /
AudioOut) and to record both the new audio family and the USB
ClassFilter → AQS work shipped earlier on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DrivePicker enumerates System.IO.DriveInfo.GetDrives() and polls every
two seconds via a DispatcherTimer to detect hot-plugged removable
drives — same DeviceState UX (Available / JustConnected / Disconnected)
and routed-event surface as the WinRT-backed pickers, same Label*
wrapper pattern. The model is named DiskDriveInfo to avoid the namespace
clash with System.IO.DriveInfo.

TimeZonePicker is the slim variant: no service, no hot-plug, no state.
It binds TimeZoneInfo.GetSystemTimeZones() directly to a ComboBox with
a default item template that prefixes each entry with its UTC offset.
LabelTimeZonePicker keeps the standard mandatory validation but skips
the disconnected-device check since time zones don't transition.

Both pickers ship the en-US / da-DK / de-DE localisation triplet for
their label and watermark strings. Five new tests cover DiskDriveInfo's
INPC and ToString contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the four sample views (DrivePicker + TimeZonePicker pages plus
their Label* wrappers) and wire the four new entries into
SamplesWpfHardwareTreeView. Hardware @Readme, top-level CLAUDE.md,
roadmap §6 candidate rows, and CHANGELOG are updated to reflect seven
pickers now shipping in Atc.Wpf.Hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picker for paired classic Bluetooth devices, built on the same
DeviceWatcherHost plumbing as serial / USB / audio. Uses
BluetoothDevice.GetDeviceSelectorFromPairingState(true) as the AQS
selector so paired devices appear / disappear live as Windows updates
its pairing list. The BluetoothDeviceInfo POCO surfaces IsPaired and
IsConnected (latter is reactive via INPC); connected items are
rendered with a ● bullet via ToString().

The full DP / routed-event surface is unchanged from the existing
pickers (Value, AutoRefreshOnDeviceChange, ClearValueOnDisconnect,
AutoRebindOnReconnect, AutoSelectFirstAvailable, ItemTemplate,
ValueChanged, DeviceLost, DeviceReconnected, DeviceStateChanged), so
consumers can swap one for another without learning a new API.

Strings localised for en-US / da-DK / de-DE. BluetoothDeviceInfoTests
cover constructor / ToString / INPC for both IsConnected and State.
BLE-only and unpaired-discovery (advertising watcher) are deferred —
they require additional capability declarations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the two sample views (BluetoothDevicePicker page + Label* wrapper)
and wire the two new entries into SamplesWpfHardwareTreeView. Hardware
@Readme, top-level CLAUDE.md, roadmap §6 row, and CHANGELOG are
updated to reflect eight pickers now shipping in Atc.Wpf.Hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two polling-based pickers for hooking debuggers, automation, capture
targets, or "attach to existing" flows.

ProcessPicker enumerates Process.GetProcesses() with OnlyWithMainWindow
defaulting true (hides background services). RunningProcessInfo exposes
PID, ProcessName, MainWindowTitle, and MainModulePath; metadata access
on protected processes is caught and skipped quietly so a single
locked-down process can't break enumeration.

WindowPicker enumerates top-level OS windows via EnumWindows P/Invoked
from user32.dll, with OnlyVisibleWithTitle defaulting true. P/Invokes
use DllImport (rather than LibraryImport) so the project can stay
without AllowUnsafeBlocks. TopLevelWindowInfo exposes the HWND, title,
class name, and the owning process name (looked up once at first sight).

Both services poll every 2 s and route through the same DeviceState
plumbing as the hardware pickers — when a tracked process exits or
window is destroyed, DeviceLost fires and the bound Value flips to
Disconnected. Strings localised for en-US / da-DK / de-DE. Eight new
model tests cover both POCOs (constructor, FriendlyName fallbacks,
ToString format, INPC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the four sample views (ProcessPicker + WindowPicker pages plus
their Label* wrappers) and wire the four new entries into
SamplesWpfHardwareTreeView. Hardware @Readme, top-level CLAUDE.md,
roadmap §6 rows, and CHANGELOG are updated to reflect ten pickers
now shipping in Atc.Wpf.Hardware (split into hardware-backed,
system-metadata, and inspection families).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more polling-based system pickers, both following the established
DispatcherTimer-poll pattern (2 s default).

NetworkAdapterPicker enumerates NetworkInterface.GetAllNetworkInterfaces()
and exposes the reactive OperationalStatus on NetworkAdapterInfo so
consumers can bind live up/down state without re-selecting. Loopback
adapters are hidden by default — flip IncludeLoopback=true on the
service to surface them. The model carries Name, Description (preferred
for FriendlyName), AdapterType (full NetworkInterfaceType enum),
MacAddress, Speed, and IsLoopback.

PrinterPicker enumerates LocalPrintServer.GetPrintQueues() across both
Local and Connections types and flags the system default with a ★ in
the dropdown via PrinterInfo.IsDefault. The model also carries IsLocal,
IsShared, and QueueStatus (the PrintQueueStatus flags as a string).

Strings localised for en-US / da-DK / de-DE. Eight new model tests
cover both POCOs (constructor / FriendlyName / ToString / INPC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the four sample views (NetworkAdapterPicker + PrinterPicker pages
plus their Label* wrappers) and wire the four new entries into
SamplesWpfHardwareTreeView. Hardware @Readme, top-level CLAUDE.md,
roadmap §6 rows, and CHANGELOG are updated to reflect twelve pickers
now shipping in Atc.Wpf.Hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picker for connected monitors / displays. Uses Win32 EnumDisplayMonitors
+ GetMonitorInfo P/Invoked from user32.dll (DllImport, mirroring the
WindowPicker pattern so the project stays without AllowUnsafeBlocks).
DisplayInfo carries the HMONITOR handle, the GDI device name (e.g.
"\.\DISPLAY1"), the full virtual-screen Bounds and the WorkingArea,
plus the IsPrimary flag from MONITORINFOF_PRIMARY.

Polls every 2 s like the other polling-based pickers; the system
primary monitor is rendered with a ★ followed by its resolution in
the dropdown (e.g. "\.\DISPLAY1 ★ (1920×1080)"). The native struct
fields keep their Win32 lower-case names (cbSize, rcMonitor,
dwFlags, szDevice) via SA1307 suppression on the helper class.

Strings localised for en-US / da-DK / de-DE. Four new model tests
cover constructor / ToString-with-and-without-star / INPC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the two sample views (DisplayPicker page + Label* wrapper) and
wire the two new entries into SamplesWpfHardwareTreeView. Hardware
@Readme, top-level CLAUDE.md, roadmap §6 row, and CHANGELOG are
updated to reflect thirteen pickers now shipping in Atc.Wpf.Hardware.
With this commit the §6 candidate list is complete (only
CulturePicker / LanguagePicker remains, intentionally deferred since
LabelLanguageSelector in Forms already covers it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull the WinRT DeviceWatcher dependency behind an internal interface
so the five WinRT-backed services (Serial / USB / UsbCamera / Audio /
Bluetooth) can be tested with a fake host instead of real hardware.

The concrete DeviceWatcherHost now implements IDeviceWatcherHost and
maps DeviceInformation to a service-shape-agnostic DeviceSnapshot POCO
inside the host before raising Added / Updated / Removed /
EnumerationCompleted. Each service consumes DeviceSnapshotEventArgs
(for Added/Updated) and DeviceRemovedEventArgs (for Removed) instead
of the previous WinRT-typed event args. RefreshAsync now goes through
the host's FindAllAsync, so initial enumeration is also mockable
without touching DeviceInformation.FindAllAsync directly.

Each service grows an internal constructor that accepts the host
(UsbDeviceService takes a Func<string, IDeviceWatcherHost> factory
because it rebuilds its watcher when ClassFilter changes). The public
parameterless / kind-only constructors are unchanged; consumers see no
API difference.

DeviceArrivedEventArgs and DeviceUpdatedEventArgs are removed in
favour of DeviceSnapshotEventArgs; DeviceRemovedEventArgs is repurposed
to carry the device id directly. The host-internal DeviceSnapshot
captures Id, Name, IsEnabled, IsDefault, Panel (CameraPanel?),
IsPaired (bool?), and PortName (string?), covering every property
the existing services read from DeviceInformation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add FakeDeviceWatcherHost in test/.../TestSupport/ — implements
IDeviceWatcherHost with public RaiseAdded / RaiseUpdated / RaiseRemoved
/ RaiseEnumerationCompleted helpers, captures FindAllAsync calls, and
exposes IsStarted / IsDisposed / Start+Stop call counts so tests can
assert lifecycle without WinRT.

Five new test files cover the WinRT-backed services through scenarios
that previously required real hardware:
- SerialPortServiceTests (10 tests): VID/PID parsing, PortName fallback
  to Name, Available↔Disconnected rebinding, RefreshAsync sync, Dispose.
- UsbDeviceServiceTests (8 tests): IsEnabled→InUse mapping, ClassFilter
  rebuild via factory + Disposes-old-host + recreates new + preserves
  started state, JustConnected after EnumerationCompleted.
- UsbCameraServiceTests (6 tests): Panel mapping incl. null→Unknown,
  IsEnabled→InUse, JustConnected, Disconnected, RefreshAsync.
- AudioDeviceServiceTests (6 tests): Kind exposure, IsDefault flag,
  IsEnabled→InUse, JustConnected, Disconnected, RefreshAsync.
- BluetoothDeviceServiceTests (6 tests): IsConnected/IsPaired wiring,
  IsPaired null→true default, repeat-Added refreshes IsConnected,
  JustConnected, Disconnected, RefreshAsync.

Hardware test count goes 61 → 95 (+34). Retires §1.5, §2.5, §3.5, §4.8
deferrals on the roadmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote the four "deferred — needs WinRT mock harness" rows in the
roadmap (§1.5 SerialPortServiceTests, §2.5 UsbDeviceServiceTests, §3.5
UsbCameraServiceTests, §4.8 connect/disconnect/in-use simulation) from
⬜ to ✅ now that FakeDeviceWatcherHost exists and the services are
covered. CHANGELOG records the IDeviceWatcherHost abstraction and the
+34 service tests as a single Unreleased entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flip Miscellaneous.Culture and Validations.Culture between
invariant / da-DK / de-DE and assert representative keys come back
in the right language. 34 cases across 11 keys:

- 8 keys from Miscellaneous: Refresh, Available, InUse, Disconnected,
  DeviceDisconnected, SelectSerialPort, AudioInput, BluetoothDevice
- 3 keys from Validations: DeviceIsRequired, DeviceNoLongerAvailable,
  DeviceCurrentlyInUse

Both test classes are in [Collection("Localization")] so the static
resource-culture flips don't race when xUnit runs them in parallel.
A separate fact verifies that an unrelated culture (ja-JP) falls back
to the invariant resource — exercising the ResourceManager fallback
chain itself.

Hardware test count goes 95 -> 129 (+34). Retires §5 last deferral
on the picker roadmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add ShowLivePreview (default false) and PreviewHeight (default 240)
DPs on UsbCameraPicker, plus an internal LiveCameraPreview UserControl
that renders frames from the selected camera into a WriteableBitmap.

The preview pipeline is WinRT MediaCapture + MediaFrameReader → each
arriving SoftwareBitmap is normalised to BGRA8 premultiplied, copied
into a managed byte[] via SoftwareBitmap.CopyToBuffer(IBuffer), and
Marshal.Copy'd into a recycled WriteableBitmap.BackBuffer on the UI
thread. No AllowUnsafeBlocks needed and no per-frame BMP encode.

Lifecycle: starts on Loaded when both DeviceId and IsActive are set;
restarts when DeviceId changes (so picking a different camera in the
ComboBox swaps the preview); stops on Unloaded, on
ShowLivePreview=false, and on Dispose. Concurrent restarts are
guarded by a startInProgress flag.

Errors are caught and surfaced inline:
- UnauthorizedAccessException → "Camera access denied" (localized)
- everything else (camera in use, no color frame source, etc.) →
  "Camera preview unavailable" (localized)

Both messages and the existing TestCamera key are localised across
en-US / da-DK / de-DE. The forwarded surface on LabelUsbCameraPicker
gets the same two DPs. Sample VM exposes them under a "Preview"
PropertyDisplay group so the explorer can toggle preview on / off and
adjust the pane height live.

Roadmap §3.2 promotes from ⏸️ to ✅; the separate PreferredFormat DP
(resolution/FPS picker) stays parked v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ormats

UsbCameraFormat (record: Width / Height / FrameRate / Subtype) joins
the model layer; ToString uses InvariantCulture so the tests aren't
fragile against da-DK / de-DE decimal separators in the dev box.

UsbCameraInfo gets [ObservableProperty] SupportedFormats — populated
lazily after the live preview opens the camera. LiveCameraPreview
enumerates MediaFrameSource.SupportedFormats inside StartInternalAsync,
maps each MediaFrameFormat to UsbCameraFormat (skipping entries
without a VideoFormat or with a zero denominator), deduplicates, sorts
descending by resolution then FPS, and raises a new FormatsAvailable
event (CameraFormatsAvailableEventArgs : EventArgs to keep Meziantou
happy). UsbCameraPicker subscribes to that event in the internal ctor
and writes the formats onto Value.SupportedFormats so consumers can
bind a separate format ComboBox.

PreferredFormat is a new UsbCameraFormat? DP on UsbCameraPicker
(BindsTwoWayByDefault) and on LiveCameraPreview. When set, the
preview calls MediaFrameSource.SetFormatAsync with the matching
SupportedFormats entry before starting the reader; mismatches and
SetFormatAsync failures fall back silently to the device default.
Format mismatch is matched on Width + Height exactly and FrameRate
within 0.5 fps, which tolerates the typical 29.97 vs 30 rounding.

LabelUsbCameraPicker forwards PreferredFormat (TwoWay). The
UsbCameraPickerView sample exposes a Resolution ComboBox bound to
Value.SupportedFormats and SelectedItem to PreferredFormat to
demonstrate the lazy-fill pattern. Demo VM gains SelectedCamera and
PreferredFormat properties.

UsbCameraFormatTests cover constructor, culture-invariant ToString,
and value-based equality. UsbCameraInfoTests grow a SupportedFormats
INPC test. Hardware tests: 129 → 134 (+5).

Roadmap §3.1 and §3.2 PreferredFormat both promote from ⬜/⏸️ to ✅.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tracks the planned live-preview work for AudioInputPicker (mic-input
waveform + peak meter) and AudioOutputPicker (test-tone playback +
visualisation), mirroring the §3.2 UsbCameraPicker live-preview
pattern. Records the design choices made in conversation:

- Visualisation: scrolling waveform + peak bar (not FFT)
- Audio engine: WinRT AudioGraph (no NuGet dependency)
- Test tone: generated 1 kHz sine, 1 s per channel L→R→Both with
  30 ms fade in/out (no embedded WAV)
- Output viz: render the generated tone (no WASAPI loopback)
- Symmetric ShowLivePreview + PreviewHeight DPs forwarded through
  the Label* wrappers
- Localisation: Test / Stop / AudioPreviewUnavailable /
  AudioPermissionDenied across en-US / da-DK / de-DE

§9.1–§9.7 break the work into shared design / input meter / output
tester / picker wiring / sample app / localisation / tests buckets,
all marked ⬜ pending implementation. §9.8 captures the three open
questions (test sound shape, output viz source, FFT alternative)
with their chosen defaults so the design rationale stays visible
when the rows flip to ✅.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ter)

Add ShowLivePreview (default false) and PreviewHeight (default 120)
DPs on AudioInputPicker plus an internal LiveAudioInputMeter
UserControl that captures from the selected microphone via WinRT
AudioGraph and renders a scrolling waveform + side peak-level bar.

Capture pipeline: AudioGraph + AudioDeviceInputNode (using the
selected DeviceInformation) + AudioFrameOutputNode wired in. Each
QuantumStarted event reads the latest float samples through a new
AudioBufferAccess helper that bridges WinRT IMemoryBufferByteAccess
to a managed Span<float>; the per-quantum peak is pushed into a
200-slot ring buffer.

The visual is a Canvas with two mirrored Polylines (top + bottom of
the midline) driven by a 30 fps DispatcherTimer reading the ring,
plus a small vertical Rectangle on the right edge showing
instantaneous peak. Errors are caught and shown inline:
- UnauthorizedAccessException → "Microphone access denied" (localized)
- everything else → "Audio preview unavailable" (localized)

WinRT's AudioBuffer doesn't expose a CopyToBuffer(IBuffer) overload
the way SoftwareBitmap does, so audio bytes are only reachable via
the IMemoryBufferByteAccess COM interface — which requires unsafe
code. AllowUnsafeBlocks is now enabled at the project level, but
unsafe is confined to AudioBufferAccess.cs and the COM-import
interface declaration in IMemoryBufferByteAccess.cs. The whole rest
of the assembly stays in safe code.

Surface forwarded through LabelAudioInputPicker; ILabelAudioInputPicker
gains the two DPs. Sample VM exposes them under a "Preview"
PropertyDisplay group.

Strings localised across en-US / da-DK / de-DE: Test, Stop,
AudioPreviewUnavailable, AudioPermissionDenied (the last two added
even though only Mic uses the permission case for now — output
side will reuse the unavailable message in the next bucket).

Roadmap §9.2 promotes from ⬜ to ✅; §9.4 / §9.5 / §9.6 input rows
flip to ✅ (output rows remain ⬜).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add ShowLivePreview (default false) and PreviewHeight (default 120)
DPs on AudioOutputPicker plus an internal LiveAudioOutputTester
UserControl that renders a 1 kHz sine test tone through WinRT
AudioGraph and visualises the same buffer as a scrolling waveform.

Render pipeline: AudioGraph configured with the selected output as
PrimaryRenderDevice, AudioFrameInputNode wired to AudioDeviceOutputNode.
QuantumStarted handler generates samples on demand: 1 second on the
left channel, 1 second on the right, 1 second stereo, with a 30 ms
linear fade in/out at every segment boundary to suppress click
artefacts. The samples we generate are also pushed into the same
200-slot ring buffer used by the input meter, so the user sees a
perfectly clean scrolling sine wave during playback.

Test/Stop is a single button — clicking while idle starts the tone,
clicking while playing stops it; auto-resets to "Test" when the
3-second tone completes naturally.

Output rendering doesn't trigger a permission prompt, so the only
error fallback is "Audio preview unavailable" (covers device held
by another app, format mismatch, etc.).

The fade-envelope math is extracted into SineToneEnvelope.cs so it
can be unit-tested independently of the AudioGraph runtime — 8 new
tests cover segment boundaries, mid-segment target, linear
fade-in/fade-out shape, past-end silence, negative-position guard,
and zero-segment-size passthrough.

LabelAudioOutputPicker forwards both DPs; ILabelAudioOutputPicker
gains the surface. Sample VM exposes them under a "Preview"
PropertyDisplay group. Hardware tests: 134 → 142 (+8).

Roadmap §9.3 / §9.4 output rows / §9.5 output rows / §9.7
sine-envelope tests all flip to ✅. With this commit the entire §9
audio live preview bucket is done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
davidkallesen and others added 22 commits May 6, 2026 00:14
AudioOutputPicker live preview was rendering the waveform correctly
but producing silent output. Root cause: the quantum-tick handler
wrapped the AudioFrame in `using`, so the frame was disposed at end
of the try block — synchronously, immediately after AddFrame queued
it. AudioFrameInputNode.AddFrame queues the frame for asynchronous
playback by the audio engine; disposing the frame before the engine
reads its buffer invalidates the underlying memory, producing silence.
The visualisation kept working because it reads the locally-stackalloc'd
`samples` span directly, not via the AudioFrame.

Drop the `using` and let the GC reclaim the frame after the engine
consumes it (the documented WinRT pattern). CA2000 suppressed at the
method level with the rationale captured in the Justification so the
pattern doesn't get "fixed" again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ample

Two more deviations from the official Microsoft AudioCreation
Scenario3_FrameInputNode sample that explain why output stayed silent
even after fixing the AudioFrame disposal:

1. Set AudioRenderCategory.Media (was .Other). The MS sample uses
   Media for FrameInputNode-backed playback. .Other is documented as
   a catch-all but in practice some output paths route differently.

2. Don't set AudioBuffer.Length explicitly. The MS sample writes the
   buffer through the COM byte access pointer and lets the lock-release
   commit the data — the AudioFrame's allocation capacity is taken as
   the data length when the buffer is in Write mode. Setting Length
   (after the data has been written but inside the same using-scope)
   appears to either be a no-op or interfere with the commit on at
   least some Windows builds.

AudioBufferAccess.WriteFloatSamples now mirrors the MS sample exactly:
LockBuffer → CreateReference → byte-access write → end of using-scope
commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stays audible

After dropping the `using` on AudioFrame the audio engine still went
silent after a single quantum — exact symptom: a small "click" from
the first frame, then nothing. Diagnosis: the C# AudioFrame wrappers
were being garbage-collected before the audio engine consumed the
queued buffer, and when the wrapper's finalizer ran it released the
underlying COM object and invalidated the buffer.

Fix: stash each AddFrame'd frame in a small rolling Queue<AudioFrame>
field (depth 32 quantums ≈ 320 ms at 48 kHz / 10 ms quanta — well
beyond any reasonable engine processing latency). The queue is
cleared on StopAsync so frames don't leak between Test sessions.

This is the standard managed-COM pattern for WinRT objects whose
lifetime is implicitly tracked by an underlying queue: keep a
managed reference until you're confident the engine is done with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tputPicker

The "click then silence" symptom was the engine being stopped before
it could play the queued frames. RequiredSamples on the first quantum
is typically very large (initial buffer-fill), so my code was
generating the entire 3-second tone in a single AudioFrame, queueing
it, then immediately seeing samplePosition >= totalSamples on the next
quantum and dispatching StopAsync — which calls graph.Stop() and
flushes the queued buffer before the engine has finished reading it.
Result: only the first ~10 ms of audio (a click) actually played.

Two fixes:

1. Cap per-quantum generation at 100 ms regardless of RequiredSamples.
   The engine will keep firing QuantumStarted to ask for the rest, so
   the tone is built up incrementally instead of in one giant frame.
   ClampSamplesPerQuantum encapsulates the logic.

2. Don't self-stop from inside the quantum handler. When samplePosition
   reaches totalSamples, just stop adding frames and return. A
   wall-clock CancellationTokenSource scheduled at StartInternalAsync
   waits for the full tone duration plus a 500 ms drain margin before
   calling StopAsync, so the engine has time to play everything that's
   already queued.

Also extracted TryBuildGraphAsync and UpdateVisualisationRing helpers
to keep StartInternalAsync and OnFrameInputQuantumStarted under the
60-line method-length limit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Picker

After three independent fixes still produce no audio with working
speakers + working visualisation, drop in:

1. Debug.WriteLine the graph encoding (subtype / sample rate / channel
   count / bits per sample / bitrate) right after CreateAsync so we
   can see what AudioGraph negotiated with the device.
2. Debug.WriteLine the OutgoingGain on both nodes and the resolved
   PrimaryRenderDevice name after wiring.
3. Per-quantum trace: quanta count, RequiredSamples, samplePosition,
   AddFrame count. Logged for the first 5 quanta and every 50th
   thereafter so we can confirm whether the engine is asking for
   samples and whether AddFrame is succeeding.
4. Log the exception in the catch (was swallowed silently).
5. Stop-time summary: total quanta + total AddFrames per session.

Plus two low-risk concrete fixes attempted alongside:
- Explicitly set frameInputNode.OutgoingGain = 1.0 and
  outputNode.OutgoingGain = 1.0 before starting (defaults are
  documented as 1.0 but worth being explicit).
- Call frameInputNode.Start() after graph.Start(). FrameInputNode is
  documented to be in Started state by default but the MS sample's
  related capture path explicitly starts nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic output revealed an InvalidCastException being thrown — and
silently swallowed — on every audio frame:

  [LiveAudioOutputTester] graph encoding: subtype=Float ... channels=8 ...
  [LiveAudioOutputTester] quantum #1 required=480 samplePosition=0/144000 added=0
  Exception thrown: 'System.InvalidCastException' in System.Private.CoreLib.dll
  [LiveAudioOutputTester] quantum #2 required=480 samplePosition=480/144000 added=1
  Exception thrown: 'System.InvalidCastException' in System.Private.CoreLib.dll
  ...

The (IMemoryBufferByteAccess)reference cast — the documented UWP
pattern that works in C++/WinRT and CCW-projected C# — throws under
the modern CsWinRT projection used by .NET 5+ / WPF. CsWinRT's
runtime-callable wrapper for a WinRT IMemoryBufferReference doesn't
expose the IMemoryBufferByteAccess COM interface to a managed
[ComImport] cast. Result: every AddFrame'd frame had its byte buffer
left untouched (zero-filled from the AudioFrame allocator), so the
audio engine read silence even though 200+ frames were queued.

Replaced the cast with manual QueryInterface + vtable invocation:

  1. Marshal.GetIUnknownForObject(reference) → raw IUnknown*
  2. Marshal.QueryInterface(unknown, IID_IMemoryBufferByteAccess, ...)
  3. Read the GetBuffer function pointer from vtable[3] (slots 0..2
     are IUnknown's QI/AddRef/Release)
  4. Invoke through a delegate* unmanaged[Stdcall]<...>
  5. Release both COM pointers

The IMemoryBufferByteAccess.cs interface declaration is no longer
needed and is deleted.

This also fixes the LiveCameraPreview frame display, which used the
exact same AudioBufferAccess helper for SoftwareBitmap reads — that
was almost certainly silently broken too, just less obviously since
camera preview consumers don't get a clear "no signal" indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the QueryInterface fix made WriteFloatSamples actually write
bytes into the buffer (no more InvalidCastException), output was still
silent. The diagnostics confirmed 300 frames being queued and
samplePosition advancing correctly to 144000/144000 — but no audio.

Root cause: AudioBuffer.Length defaults to 0 under CsWinRT. The audio
engine reads only Length bytes from each frame, regardless of how
much data is in the buffer's capacity. Without setting Length, every
frame told the engine "I contain 0 bytes of valid audio" and got
played as silence.

The MS UWP AudioCreation sample doesn't set Length and works because
UWP's WinRT runtime initialises Length to capacity when LockBuffer is
called in Write mode. CsWinRT projection used by .NET 5+ / WPF
doesn't do that initialisation; we have to set it ourselves.

WriteBytes now returns the float count actually copied; WriteFloatSamples
sets buffer.Length = floatsWritten * sizeof(float) before disposing
the buffer. Length must be set while the AudioBufferReference is still
alive (otherwise it appears not to commit through to the AudioFrame).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The diagnostics now report the precise failure mode:

  System.ArgumentException: The parameter is incorrect.
  Length must be greater than zero
     at ABI.Windows.Media.Audio.IAudioFrameInputNodeMethods.AddFrame(IObjectReference _obj, AudioFrame frame)

So AddFrame is rejecting every frame because the underlying AudioBuffer
reports Length == 0, even though the previous fix calls
buffer.Length = bytesWritten before disposal. Under CsWinRT, setting
Length while an IMemoryBufferReference is alive is a no-op — the
property setter doesn't propagate back through to the WinRT object's
buffer state.

Fix: set Length BEFORE creating the reference. We know exactly how
many bytes we're going to write (source.Length * sizeof(float)),
clamped to the buffer's capacity, so the order becomes:

  1. LockBuffer (Write mode)
  2. Read Capacity, compute bytesToWrite
  3. Set buffer.Length = bytesToWrite
  4. CreateReference
  5. WriteBytes via QI'd byte pointer
  6. Dispose reference, then dispose buffer (commits both data + Length)

WriteBytes drops its now-unused return value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After research into how AudioGraph + FrameInputNode behaves under
CsWinRT vs UWP, three layered mistakes were producing silence on a
working 7.1 Realtek device:

1. Using graph.EncodingProperties (8-channel) for the FrameInputNode.
   On multichannel hardware the device's WAVE_FORMAT_EXTENSIBLE
   channel mask routes Left=ch0/Right=ch1 to the *rear/side*
   speakers, not the front L/R that are actually wired to a
   stereo setup. Result: every byte the engine reads is sent to
   physical channels that don't exist.

   Fix: build an explicit stereo float AudioEncodingProperties via
   CreatePcm(48000, 2, 32) with Subtype = "Float" and pass that to
   CreateFrameInputNode. AudioGraph up-mixes stereo → device layout
   correctly.

2. Allocating the AudioFrame larger than what we wrote, then trying
   to set AudioBuffer.Length after-the-fact. The Length setter is
   unreliable under CsWinRT and the canonical Windows-universal-samples
   Scenario3 sample never touches it — it allocates the frame at
   exactly samples * sizeof(float) * channelCount and that allocation
   size IS the buffer length the engine reads.

   Fix: allocate per-quantum at exactly args.RequiredSamples * 4 * 2
   bytes. Drop the cap-to-100ms ClampSamplesPerQuantum helper.
   AudioBufferAccess.WriteFloatSamples no longer touches Length.

3. Holding queued AudioFrames alive in a field-level Queue. The UWP
   sample lets each frame go out of scope after AddFrame; the engine
   takes its own ABI ref. Holding the C# wrapper alive past that
   under CsWinRT has been observed to make the engine skip frames
   defensively.

   Fix: drop the inflightFrames queue and the FrameKeepAliveDepth
   constant.

Net effect on the QuantumStarted handler: it now mirrors the canonical
Scenario3_FrameInputNode pattern exactly. The wall-clock auto-stop and
the QI'd IMemoryBufferByteAccess byte access stay (those are still
required under CsWinRT).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…memory WAV

The AudioGraph + AudioFrameInputNode pipeline kept producing silence on
working speakers under CsWinRT despite repeated fixes (channel-mask
routing, AudioBuffer.Length timing, frame keep-alive lifetime, manual
QueryInterface for IMemoryBufferByteAccess). Even a clean run with
300 frames AddFrame'd and samplePosition reaching the expected
144000/144000 produced no audio.

Switch the test playback to a different code path entirely:

- Generate a 16-bit PCM stereo WAV in memory (1 kHz sine, 3 s split
  Left -> Right -> Both with 30 ms fade in/out per segment) via the
  new internal WavGenerator helper.
- Hand it to Windows.Media.Playback.MediaPlayer with AudioDevice set
  to the picker's selected DeviceInformation, then Play(). Different
  Media Foundation render path; not affected by whatever ABI quirk
  was silencing the AudioFrame pipeline.
- The waveform pane still animates: a DispatcherTimer recomputes the
  same envelope from wall-clock elapsed and pushes synthetic samples
  into the ring buffer, so the picker visualises what is being heard.
- MediaFailed surfaces error + HRESULT to Debug + the inline localized
  "Audio preview unavailable" message; MediaEnded auto-stops cleanly.

Update AudioOutputPicker_Readme.md to describe the new MediaPlayer-
based playback path. AudioBufferAccess.cs is kept (still needed by
LiveAudioInputMeter for capture-side buffer marshalling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LiveAudioOutputTester, LiveAudioInputMeter, and LiveCameraPreview were
all using AtcApps.Brushes.Gray9 as the preview-pane background. In
the dark theme that brush resolves to #FFC9C9C9 (a bright light grey)
because Gray9 is *not* theme-adaptive — the same hex with different
alpha across the two themes. The result was a glaring light panel
inside a dark-themed picker.

Switch to AtcApps.Brushes.Gray10:
  * Light: #FFF7F7F7 (subtle elevation against white)
  * Dark:  #FF2F2F2F (subtle elevation against the #1E1E1E window)

Same surface intent, theme-adaptive on both ends. Waveform line and
Test button remain legible in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….Forms

TimeZonePicker has nothing to do with hardware enumeration — it just
calls TimeZoneInfo.GetSystemTimeZones(). Belongs alongside the other
labeled form pickers, not next to SerialPortPicker / UsbPortPicker.

Library:
  * src/Atc.Wpf.Hardware/{Pickers/TimeZonePicker.*, LabelTimeZonePicker.*,
    Pickers/Internal/TimeZonePickerAutomationPeer.cs,
    Abstractions/ILabelTimeZonePicker.cs}
    -> counterparts under src/Atc.Wpf.Forms/.
  * Namespaces renamed Atc.Wpf.Hardware{,.Pickers,.Pickers.Internal,
    .Abstractions} -> Atc.Wpf.Forms{,.Pickers,.Pickers.Internal,
    .Abstractions}.
  * Forms GlobalUsings.cs picks up Atc.Wpf.Forms.Pickers and
    .Pickers.Internal so the labeled wrapper can resolve the bare picker
    and its automation peer.
  * Forms AssemblyInfo.cs registers Atc.Wpf.Forms.Pickers under the
    unified atc: schema URI; <atc:TimeZonePicker> / <atc:LabelTimeZonePicker>
    keep working unchanged.

Resources:
  * Added TimeZone + SelectTimeZone keys to the *shared*
    Atc.Wpf.Controls.Resources.Miscellaneous (en-US / da-DK / de-DE).
    Picked Controls instead of a new Forms.Resources.Miscellaneous
    because Forms already imports Controls.Resources globally and a
    second Miscellaneous class would have collided.
  * Removed the same keys from Atc.Wpf.Hardware.Resources.Miscellaneous
    (the Designer regenerated cleanly on build).
  * LabelTimeZonePicker.IsValid now reports the localized
    Forms.Validations.FieldIsRequired instead of
    Hardware.Validations.DeviceIsRequired (which read wrong for a
    timezone field).

Sample app:
  * sample/Atc.Wpf.Sample/SamplesWpfHardware/{Pickers/TimeZonePicker*.cs/xaml,
    LabelControls/LabelTimeZonePicker*}.{xaml,xaml.cs} ->
    SamplesWpfForms/{Pickers,LabelControls}/.
  * SamplesWpfHardwareTreeView.xaml: removed the two TimeZone entries.
  * SamplesWpfFormsTreeView.xaml: added LabelTimeZonePickerView under
    "Label Controls -> Pickers" and a new top-level "Pickers" group
    for the bare TimeZonePickerView.

Docs:
  * CLAUDE.md, docs/Hardware/@Readme.md, docs/roadmap-pickers.md updated.
  * DrivePicker_Readme.md cross-reference notes the new Forms location.
  * Sample-application paths in the moved readmes point at SamplesWpfForms.
  * CHANGELOG entry rewritten to reflect the split.

git mv preserves history on every moved file. Full solution build green;
all 2073 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five foundational improvements; each addresses a long-standing gap that
prevented the control from being usable in long-running, high-volume,
production-grade scenarios.

1. Virtualization
   The ListView now declares VirtualizingPanel.IsVirtualizing="True" with
   VirtualizationMode="Recycling" and pixel scroll. Scrolling tens of
   thousands of rows stays smooth instead of stalling per row.

2. Channel-based batched ingestion (TerminalViewer pattern)
   AddEntry no longer touches the dispatcher per entry. Entries are
   written into an unbounded Channel<ApplicationEventEntry>; a background
   drain loop reads the entire pending batch, dispatches to the UI thread
   under a single Application.Current.Dispatcher.InvokeAsync(Background),
   and emits one ApplicationMonitorScrollEvent per batch instead of per
   entry. The CTS / drain task / Channel.Writer lifecycle mirrors
   TerminalViewer's. In design mode the loop is not started — the
   constructor falls through to a synchronous AddRange so the demo VM
   still renders sample entries.

3. MaxEntries ring-buffer cap
   New View DP MaxEntries (default 10000, 0 = unbounded), bridged to
   ApplicationMonitorViewModel.MaxEntries via the existing AutoScroll
   bridge. After every batched insert the loop calls TrimToCap(), which
   drops Entries[0] (oldest by insertion order) until Count <= cap.
   Works regardless of CollectionView sort direction since trim happens
   on the underlying ObservableCollection.

4. Smart auto-scroll ("tail mode") + Jump-to-live overlay
   ScrollViewer.ScrollChanged is now hooked on the ListView. When the
   user scrolls more than ~2 px away from the tail (bottom for ascending
   sort, top for descending), auto-scroll suppresses and an absolute-
   positioned "↓ Jump to live (N)" button appears bottom-right with a
   live count of entries received since detachment. Click — or call the
   public JumpToLive() — to scroll to the tail and reset. Two new DPs
   (IsDetachedFromTail, NewSinceDetached) back the overlay binding.

5. IsPaused toggle
   New View DP IsPaused (two-way, default false) + ShowPauseInToolbar
   DP. While paused, the drain loop yields without dispatching; the
   channel keeps capturing. The new toolbar Pause button shows a glyph
   (rendered via PausedToGlyphValueConverter — flips between Pause /
   Play depending on state) and a small accent-coloured badge with
   BufferedCount when items are queued (read straight from
   entryChannel.Reader.Count). Resuming flushes the held entries on the
   next drain tick.

The View's DataContextChanged bridge that previously synchronized
AutoScroll to the VM now also propagates MaxEntries and IsPaused, and
the OnViewModelPropertyChanged switch grew matching arms so the
toolbar Pause toggle stays in sync with externally-set DPs.

VM disposes the channel writer + cancellation source cleanly on
shutdown, mirroring TerminalViewer's continuation-based CTS dispose to
avoid racing the in-flight InvokeAsync.

Readme rows added for the five new DPs.

Tested manually via the existing Components sample. All 177
Atc.Wpf.Components.Tests pass; full solution test run (2073 tests)
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… ApplicationMonitorView

Two new capabilities round out the picker for production use:

1. Export to CSV / JSON / TXT
   New internal ApplicationMonitorExportService writes
   IEnumerable<ApplicationEventEntry> to disk in three formats:
   - CSV — header row + RFC-4180 quoted cells (handles ',', '"', newlines).
   - JSON — pretty-printed array of { timestamp, category, area, message }.
   - TXT — pipe-delimited, one entry per line.
   Service creates the parent directory when missing; output is UTF-8.
   Format is inferred from the chosen file extension (.csv / .json /
   .txt|.log; CSV fallback). New public ApplicationMonitorExportFormat
   enum.

   ExportCommand on the VM opens Microsoft.Win32.SaveFileDialog with
   the three filters, exports the *visible* (filtered) view (so users
   get what they see, not silently more), and surfaces failures via
   MessageBox rather than crashing the picker. Defaults the file name
   to log-yyyyMMdd-HHmmss.csv.

   New ShowExportInToolbar DP (default true) and an Export ⤓ toolbar
   button keyed off ExportCommand. ExportCommand.RaiseCanExecuteChanged
   is wired into Entries.CollectionChanged so the button enables/
   disables correctly with entry count.

2. Microsoft.Extensions.Logging provider
   New Atc.Wpf.Components.Monitoring.Logging namespace with three
   types — drops the picker straight into any
   Microsoft.Extensions.Hosting application:

   * ApplicationMonitorLogger — ILogger that maps LogLevel to
     LogCategoryType (Trace/Debug/Information/Warning/Error/Critical;
     None disables), uses categoryName as Area, and runs the user's
     formatter to produce Message. Exception text is appended on
     a new line. Sends the resulting ApplicationEventEntry through the
     supplied IMessenger.
   * ApplicationMonitorLoggerProvider — singleton
     [ProviderAlias("AtcWpfApplicationMonitor")] ILoggerProvider that
     caches a per-categoryName logger via ConcurrentDictionary.
     Defaults to Messenger.Default; accepts an optional IMessenger and
     a LogLevel filter for the noisy-Trace/Debug case.
   * ApplicationMonitorLoggingBuilderExtensions — two
     IServiceCollection-friendly overloads:
       builder.Logging.AddAtcWpfApplicationMonitor();
       builder.Logging.AddAtcWpfApplicationMonitor(
           level => level >= LogLevel.Information);
     Both register a singleton ILoggerProvider.

   With this in place, ILogger<MyService> calls in any hosted .NET
   service light up the live picker view with zero glue code — and
   because the provider is keyed by ProviderAlias, log filtering
   from appsettings.json works out-of-the-box:
       "Logging": {
         "AtcWpfApplicationMonitor": { "LogLevel": { "MyApp": "Debug" } }
       }
   The logging types lean on the Microsoft.Extensions.Logging.Abstractions
   that already comes in transitively via the Atc package — no new
   PackageReference needed.

CHANGELOG entry covers both commits.

All 2073 tests in the solution pass. Solution builds clean with zero
warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rView

- Localize toolbar tooltips, Jump-to-live label, and export dialog strings
  via Miscellaneous.resx (en / da-DK / de-DE)
- Replace MessageBox export-failure popup with themed InfoDialogBox
- Convert hand-rolled INPC properties (Filter, AutoScroll, SelectedEntry,
  ShowColumnArea, IsPaused, ListenOnToastNotificationMessage) to
  [ObservableProperty]; keep MaxEntries / SortDirection / MatchOnText
  hand-rolled where setter logic requires it
- Reorganize dependency properties in code-behind into grouped sections
  (toolbar visibility / VM-bridged behavior / read-only state / layout)
- Default ShowExportInToolbar to false (opt-in) and update readme
- Stabilize toolbar glyph alignment by hosting glyph TextBlocks in
  fixed 16x16 Grids
- Extend demo VM with ShowPauseInToolbar, ShowExportInToolbar,
  AutoScroll, IsPaused, and MaxEntries bindings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four tiers of work landing together; each tier builds on the previous,
all are user-visible, none has independent meaning.

Tier 1 — scale + UX foundation
  * VirtualizingPanel.IsVirtualizing="True" + Recycling + pixel scroll on
    the inner ListView.
  * MaxLines DP (default 10000, 0 = unbounded) — ring-buffer trim of the
    oldest lines after every batched dispatch.
  * IsPaused DP + ShowPauseInToolbar — pause stops dispatch while the
    channel keeps capturing; the toolbar Pause toggle shows a badge with
    the buffered-but-not-shown count (BufferedCount).
  * AutoScroll DP + smart-scroll heuristic — only flips out of tail mode
    on a *user-driven* ScrollChanged (VerticalChange != 0) and skips its
    own programmatic scrolls (isPerformingProgrammaticScroll guard,
    reset at DispatcherPriority.ApplicationIdle so pending events can
    settle). ScrollToTail uses ListView.ScrollIntoView(Items[^1]) as the
    primary path (reliable under virtualization) with
    ScrollViewer.ScrollToBottom as belt-and-braces.
  * "↓ Jump to live (N)" overlay with new-since-detached counter, plus
    JumpToLive() public API.
  * Visible toolbar: Clear · Copy · | · AutoScroll · Pause + count.

  Drain loop reads volatile snapshot fields (isPausedSnapshot,
  autoScrollSnapshot, enableAnsiParsingSnapshot) instead of the DPs
  directly — accessing DispatcherObject DPs from a background thread
  throws InvalidOperationException, kills the drain task and silently
  strands all subsequent input.

Tier 2 — investigation power
  * SearchText + UseRegex + HideNonMatching DPs, with live MatchCount
    badge ("N matches") next to the search box.
  * TerminalLineHighlight attached property — rebuilds TextBlock.Inlines
    with substring/regex matches highlighted in the accent brush; bad
    regex falls back to plain rendering.
  * SelectionMode="Extended" + new CopySelectedToClipboard command and
    context-menu entry.
  * Export to .txt / .log via SaveFileDialog — failures route through
    InfoDialogBox with localized title/body.
  * KeyBindings: Ctrl+F focus search, Ctrl+L clear, Ctrl+S export,
    Ctrl+G jump-to-live, Ctrl+P pause, F3 / Shift+F3 next/prev match,
    Esc clear search, Ctrl + / Ctrl - / Ctrl 0 zoom.

Tier 3 — display polish
  * TerminalLineItem now also carries Timestamp (DateTimeOffset, defaults
    to Now), LineNumber (1-based, viewer-scoped) and IsPinned.
  * ShowTimestamps + ShowLineNumbers DPs render leading muted-coloured
    prefixes via TerminalLineHighlight.
  * WordWrap DP + toolbar toggle, backed by new
    BoolToTextWrappingValueConverter (true → Wrap, false → NoWrap).
  * ZoomInCommand / ZoomOutCommand / ZoomResetCommand clamped to 6–48 px,
    bound to keyboard above plus a "Zoom in/out/reset" context-menu
    block.
  * TogglePinSelected command + context-menu entry. Pinned lines survive
    ClearScreen (the Clear handler removes everything except pinned items
    and resets the line counter).

Tier 3.6 — ANSI escape parsing (★ killer feature)
  * AnsiPalette — frozen 16-colour Windows-Terminal palette.
  * AnsiSgrState record + AnsiSequenceParser.Parse(text, state) →
    (List<TerminalRun>, NewState) — handles SGR (reset, bold/22,
    italic/23, underline/24, fg 30–37/39/90–97, bg 40–47/49/100–107,
    256-colour 38;5;n for n<16, true-colour 38;2;R;G;B). Strips
    non-SGR sequences silently. State carries across lines so
    multi-line colours render correctly.
  * TerminalRun record (Text + Foreground + Background + Bold + Italic
    + Underline). TerminalLineItem.Runs is the opt-in field; the
    highlight attached property renders runs verbatim when present and
    falls back to plain text + search highlight otherwise.
  * EnableAnsiParsing DP (default true) + cheap
    ContainsEscapeSequence pre-check so plain-text lines bypass the
    parser. ANSI state resets to Default on Clear.

Sample / demo
  * New TerminalViewerDemoViewModel with 16 [PropertyDisplay] +
    [ObservableProperty] entries (Toolbar / Behavior / Display groups)
    so the right-side Properties panel drives every exposed DP.
  * TerminalViewerView no longer sets DataContext = this — that override
    silently broke parent-set bindings ({Binding AutoScroll, Mode=TwoWay}
    on the <atc:TerminalViewer> element) by self-targeting the DP.
    Internal bindings now use RelativeSource AncestorType=UserControl
    and the ContextMenu uses PlacementTarget.Tag.X with Tag bound to the
    UserControl.
  * Demo XAML rewrites: directly placed in <atc:GridEx Columns="*,400">
    instead of nested in ScrollViewer + UniformSpacingPanel(MinHeight=300)
    — that wrapper let the inner ListView grow unbounded, defeated
    virtualization, and scrolled the toolbar off-screen. New "Send ANSI
    sample" and "Send burst (100 lines)" buttons added.

Localization
  * New shared keys in Atc.Wpf.Controls.Resources.Miscellaneous (en/da/de):
    ExportTerminalOutput, CouldNotExportTerminalOutputFormat1,
    ExportVisibleEntriesTooltip, PauseResumeIncomingEntriesTooltip,
    JumpToLive, ResumeTailModeTooltip, ExportLog, ExportFailed,
    CouldNotExportLogFormat1.
  * PausedToGlyphValueConverter is now public so TerminalViewer's XAML
    can reach it (was internal in Monitoring; both controls now use the
    same converter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same architectural bug as TerminalViewer: programmatic ScrollIntoView
fires ScrollChanged with VerticalChange != 0; under WPF virtualization
the post-scroll VerticalOffset is not exactly ScrollableHeight - 2px
(extent grows as more items realize), so the at-tail predicate returns
false and the handler latches the viewer as "user-detached" — silently
disabling auto-scroll for the rest of the session.

Mirrors the TerminalViewer fix:
  * isAtTail field renamed to isUserDetached. The handler now skips
    state updates when (a) VerticalChange == 0 (pure layout change) or
    (b) isPerformingProgrammaticScroll is set.
  * New ScrollToTail(direction) helper sets the guard, calls
    LvEntries.ScrollIntoView(...) (reliable under virtualization), and
    resets the guard at DispatcherPriority.ApplicationIdle so any
    pending ScrollChanged events have already fired.
  * OnApplicationMonitorScrollEvent and JumpToLive both route through
    ScrollToTail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nMonitor

Two tests in test/Atc.Wpf.UiTests/ that drive the sample app and verify
auto-scroll engages once content overflows the viewport. Both belong to
the [Collection("SampleApp")] xunit collection so they run serially —
launching two sample-app instances in parallel races on UIA window
lookup.

* AutoScrollTestHelpers.cs — launches the sample, maximizes the window,
  navigates via the TbSampleFilter textbox (typing the leaf name hides
  every other item, dodging WPF tree-view virtualization that otherwise
  prevents real mouse clicks against off-screen TreeViewItems). The
  custom SampleTreeViewItem.OnMouseLeftButtonDown override doesn't
  cooperate with UIA's SelectionItemPattern.Select() (times out), so
  navigation uses a real Click() with ScrollItemPattern.ScrollIntoView()
  beforehand.

* TerminalViewerAutoScrollTests — fires 5 × Send burst (100 lines) and
  asserts the highest realized "burst NNNNN" is within 30 of the tail
  (i.e. close to 500). Reads the realized TextBlock contents via
  ControlType.Text descendants because the DataTemplate populates
  TextBlock.Inlines through the TerminalLineHighlight attached property
  — UIA's ListItem.Name doesn't surface those inlines.

* ApplicationMonitorAutoScrollTests — clicks Add many items 6× (60
  entries) and asserts the most recent visible timestamp is within 10 s
  of DateTime.UtcNow. Entries' timestamps are captured as
  DateTimeOffset.UtcNow and rendered without zone designator, so the
  test parses as UTC and compares with UtcNow.

Also fixes test/Atc.Wpf.UiTests/SampleAppPath.cs: prefer the sample's
own bin/ folder over the copy MSBuild dropped alongside the test exe.
The sample's view-loader walks up from the running exe until it finds
a "bin" folder, then takes its parent as the project root and searches
there for sample XAML files. From the test's bin/ that root would be
the test project (which has no sample sources) — every sample-leaf
click fails with "Can't find sample by invalid location". Running the
source-tree exe puts that root at the sample project, where the XAMLs
live.

These tests run automatically in `dotnet test` (no Skip attribute) —
they're [Trait("Category", "UI")] so CI can opt out via filter if a
headless runner can't host the sample.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AudioInputPicker preview rendered a flat line because the manual
Marshal.GetIUnknownForObject + QueryInterface path returned E_NOINTERFACE
for read-mode IMemoryBufferReference instances under CsWinRT — the CCW
returned by GetIUnknownForObject did not expose IMemoryBufferByteAccess.
Switch to WinRT.CastExtensions.As<T>(), which routes through the ABI
object that does expose it. Drops the diagnostic logging added during
the investigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Setting DataContext = this in the constructor broke inheritance from
the consumer's DataContext, so consumer bindings like
LabelText="{Binding LabelText}" resolved against the LabelColorPicker
itself (looping back to the same DP) instead of the parent ViewModel.
The control's internal template uses RelativeSource bindings, so it
doesn't need the override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a selected device transitioned to InUse or Disconnected, the bare
picker rendered the message in a second Auto-sized row inside its own
grid. The picker grew taller, pushing every neighbouring control down.

Each bare picker now exposes a ShowSelectedStateMessage DP (default
true) that gates the inline TextBlock's visibility. The Label* wrapper
sets it to false and routes the state message through ValidationText
instead, so the message lands in the LabelContent validation slot
shared with LabelTextBox-style controls — same position, ValidationColor
styling, no layout shift. Each wrapper also subscribes to the device
info's PropertyChanged so an InUse transition that happens after
selection re-validates immediately (the existing OnValueChanged hook
only fires on reference changes).

Applied to all 12 picker pairs: SerialPort, UsbPort, UsbCamera,
AudioInput, AudioOutput, Drive, Bluetooth, Process, Window,
NetworkAdapter, Printer, Display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Hardware TreeView style entry alongside the other section
trees in App.xaml, and collapses an attribute-per-line LabelUsbPortPicker
declaration onto a single line to match the second picker in the same
view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
davidkallesen and others added 2 commits May 7, 2026 00:32
…mple, uitests

Release builds treat warnings as errors, which surfaced ~125 analyzer
violations that the Debug build had been masking. None were behavioural
bugs in code paths users hit; they're style + safety hygiene the project
already enforces elsewhere.

Components:
  - Add ArgumentNullException.ThrowIfNull guards on every public attached
    property accessor in TerminalLineHighlight (CA1062).
  - Refactor TerminalViewer.ProcessQueueContinuously and
    ApplicationMonitorViewModel.ProcessQueueContinuouslyAsync into smaller
    DrainAndDispatchBatchAsync + CommitBatchOnUiThread helpers so each
    method stays under 60 lines (MA0051).
  - Math.Abs(VerticalChange) < double.Epsilon instead of == 0 in both
    scroll-changed handlers (S1244).
  - _ = Dispatcher.BeginInvoke(...) for fire-and-forget continuations
    (MA0134).
  - Move Microsoft.Extensions.Logging / DependencyInjection usings to
    GlobalUsings (ATC221) and pass StringComparer.Ordinal to the logger
    provider's ConcurrentDictionary (MA0002).
  - StringComparison.Ordinal on the CSV escape Contains/Replace calls
    (CA1307).
  - Drop the decorative `// ----` comment dividers; the analyzer rule
    suite (SA1512/SA1514/SA1518) doesn't tolerate them either before or
    after blank lines, and they were noise.
  - Reorder readonly fields ahead of non-readonly fields (SA1214) and
    strip trailing newlines repo-wide per `.editorconfig`.

Hardware:
  - Refactor LiveAudioInputMeter.StartInternalAsync into TryCreateGraph /
    CreateStereoFloatEncoding / TryCreateNodes helpers; refactor
    WavGenerator.CreateStereoTestTone into WriteRiffHeader / WriteSamples
    helpers (MA0051).
  - .ConfigureAwait(false) on the device-watcher RefreshAsync calls in
    AudioDevice / BluetoothDevice / SerialPort / UsbCamera / UsbDevice
    services (MA0004) and on the LiveAudioInputMeter StopAsync awaits.
  - Move System.IO / System.Text usings to Hardware's GlobalUsings;
    re-format IMemoryBufferByteAccess.GetBuffer's params onto separate
    lines (ATC221, ATC202).

Sample:
  - Replace literal ESC bytes in the TerminalViewer ANSI demo strings
    with `�` escape sequences (S2479).
  - Switch the demo VM's EnableTimerChanged event from EventHandler<bool>
    to plain EventHandler; the consumer reads the property directly
    (CA1003, MA0046).

UI tests:
  - Move file-level usings into GlobalUsings, drop trailing newlines,
    suppress S2925 (Thread.Sleep is the established wait mechanism in
    FlaUI E2E tests) and CA1031 (best-effort screenshot capture must
    survive transient I/O), add Regex timeouts (MA0009), break
    multi-param signatures onto separate lines (ATC202), and split a
    couple of single-line try/catch statements (SA1501).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…itorconfigs

Picks up Meziantou.Analyzer 3.0.71, SonarAnalyzer.CSharp 10.25, MS Test
SDK 18.5.1, and TrxReport 2.2.2 — required for the Release build to be
deterministic against the new analyzer rules. Adds the standard atc-net
.editorconfig file (rule severity overrides + naming conventions) to the
four projects that were missing one: Atc.Wpf.Hardware,
Atc.Wpf.Benchmarks, Atc.Wpf.FontIcons.Tests, and Atc.Wpf.UiTests; the
content matches the sibling projects (e.g. Atc.Wpf.Network) so behaviour
stays consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@davidkallesen
davidkallesen merged commit a7a3868 into main May 6, 2026
2 checks passed
@davidkallesen
davidkallesen deleted the feature/pickers branch May 6, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant