Add Atc.Wpf.Hardware pickers and upgrade Monitor/Terminal viewers - #191
Merged
Conversation
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>
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
force-pushed
the
feature/pickers
branch
from
May 6, 2026 21:45
5016e75 to
55ab273
Compare
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
✨ Features
🐛 Fixes
♻️ Refactoring
📝 Documentation