Fix M-display losing its layout after a restart (duplicate-serial disambiguation) - #2
Merged
Merged
Conversation
The path from hardware sensors to LCD pixels, end to end:
- RenderDataHub (Rendering): copy-on-write key/value snapshot store;
display loops read an immutable snapshot per frame with no lock on
the render path. DeviceDisplayLoop takes an optional
IRenderDataProvider and feeds it into WidgetRenderContext.Data.
- SystemStatWidget ("stat"): renders one labeled numeric reading
("CPU 43%") from a configured dataKey, with "--" as the placeholder
when the sensor is absent. TextWidget ("text") for static labels.
Shared WidgetTextRenderer centers and shrinks-to-fit text; ClockWidget
refactored onto it.
- SystemStatsPublisher + SystemStatKeys (DataSources): maps a
SystemStatsSnapshot onto stable, config-referenced data keys;
missing sensors remove their key so widgets fall back to the
placeholder instead of showing stale numbers.
- SystemMonitorPumpService (App): BackgroundService polling
LibreHardwareMonitor every 2s into the hub; poll failures are logged
and skipped.
- Default auto-provisioned layout is now clock + CPU load / CPU temp /
RAM row (existing saved configs are untouched).
79 tests passing (14 new: hub semantics, publisher key lifecycle, stat
widget rendering incl. placeholder/non-numeric paths, factory parsing,
loop data flow).
Replaces the M1 read-only clock preview with a real per-device layout editor - the goal being that a user never opens config.json by hand: - Pick a device from a dropdown (reads saved DeviceProfileConfig list; works without the device plugged in, since resolution/widgets are already persisted). - Add Clock / Text / System Stat widgets from toolbar buttons. - Click a widget to select it, drag its body to move, drag a corner handle to resize - both clamped to stay fully on-canvas. This is a transparent WPF overlay (DesignerWidgetOverlay) laid over a single SKElement that composes the *real* FrameCompositor output each tick, so the preview is pixel-for-pixel what the physical display shows, not a mockup. - A property panel built per widget type from dropdowns and text fields only (time format presets, a curated color palette, a friendly list of available sensor readings for stat widgets) - no free-form JSON anywhere. - Save writes the edited layout back through ConfigService. Two supporting changes so edits actually reach the screen: - WidgetDesignItem (Ax206Display.Rendering): a mutable, testable FromConfig/ToConfig wrapper around the immutable WidgetConfig record, since dragging/resizing needs in-place mutation. - DeviceDisplayLoop.UpdatePlacements + a 3s config-poll task in DisplayManagerHostedService: a running display now picks up a saved Widget Designer edit without an app restart. Also: a Windows-only NuGet restore for Ax206Display.App turns out not to require a Windows host after all (only building against the WindowsDesktop SDK does) - generated and committed its packages.lock.json and switched the Windows CI job to --locked-mode, closing a gap the workflow had flagged since M1. 87 tests passing. The WPF designer UI itself (WidgetDesignerWindow, DesignerWidgetOverlay, WidgetCatalog) can only be compiled by the Windows CI job - this sandbox has no WindowsDesktop SDK workload to verify it against locally.
SaveButton.IsEnabled already tracks whether there are unsaved changes; _isDirty duplicated that without ever being read anywhere.
- Network speed: INetworkSpeedSource/NetworkInterfaceSpeedSource sums bytes sent/received across all up, non-loopback/tunnel adapters and reports the rate since the previous poll (NetworkSpeedRateCalculator clamps a counter reset to zero instead of a negative speed). NetworkSpeedPumpService polls every 2s and publishes Download/Upload in Mbps into the existing RenderDataHub under new NetworkSpeedKeys - they show up as ordinary entries in the Widget Designer's "System Stat" reading dropdown, no new widget type needed. - Fonts: WidgetTextRenderer takes an optional fontFamily, resolved via SKTypeface.FromFamilyName and disposed correctly (SKTypeface.Default is a shared instance and is never disposed) - unknown/uninstalled family names fall back gracefully rather than throwing. Threaded through Clock/Text/Stat widgets and WidgetFactory; the Designer gets a "Font" dropdown (Default + 10 common Windows-installed families) next to the existing color picker on every widget type. - Colors: palette grown from 9 to 17 curated, LCD-legible swatches. 93 tests passing (8 new: rate-calculator edge cases, publisher Mbps conversion and key lifecycle, factory font-setting parsing, and an unknown-font-family render regression test).
…X fixes Formatting: - WidgetFontStyle (a record CLASS - see its doc comment for why a struct silently zeroed SizeScale via new WidgetFontStyle(), a real bug caught by the test suite before it shipped) replaces the lone fontFamily string threaded through Clock/Text/Stat widgets. Bold and Italic resolve to an SKFontStyle via SKTypeface.FromFamilyName; typefaces are deliberately never disposed (a font+style typeface can alias a shared/cached instance - disposing one broke SKTypeface.Default itself on this environment's limited font set). Designer gets Bold/ Italic checkboxes and a Small/Default/Large/Extra Large size dropdown next to the existing font/color pickers. Backgrounds: - DeviceProfileConfig.BackgroundImagePath; FrameCompositor.ComposeFrame draws it stretched-to-fill beneath all widgets. DeviceDisplayLoop gets UpdateBackgroundImage (mirrors UpdatePlacements) so a saved change reaches the running display without a restart; DisplayManagerHostedService's config-poll loop only re-decodes the file when its path actually changes. Designer gets "Background..." (Microsoft.Win32.OpenFileDialog) and "Clear Background" buttons. Grid overlay: - A session-local "Show Grid" checkbox draws evenly-spaced alignment lines directly in the preview's paint surface - a design-time aid only, never persisted or sent to the physical display. Three UX fixes: - The app now opens the Widget Designer on startup instead of only putting an icon in the tray with nothing visible. - Tray Exit actually quits: it called IHostApplicationLifetime .StopApplication(), which stops hosted services but has no connection to the WPF Application's lifetime - with ShutdownMode=OnExplicitShutdown nothing else ever called Shutdown(), so the process lingered until killed from Task Manager. Now calls Application.Current.Shutdown(), which drives App.OnExit's existing host.StopAsync()/Dispose() correctly. - (Unrelated to this session's other startup logs: a rejected USB device during discovery and a mid-blit NoDevice disconnect are pre-existing, separate issues - not touched here.) 104 tests passing.
Root cause of the NoDevice exception seen earlier: once DeviceDisplayLoop.RunAsync threw (a USB disconnect mid-blit), the per- device task just logged a warning and ended for good - the display stayed dark until the whole app was restarted. DisplayManagerHostedService now supervises each device slot instead of firing its loop once: on any loop failure, it disposes the dead transport, waits 5s, and re-runs discovery looking for the same device by DeviceId. If found, it rebuilds placements/background from the current config and resumes rendering; if not, it keeps retrying every 5s until it reappears or the app shuts down. Two correctness points worth calling out: - Discovery scans are now serialized through a semaphore (DiscoverSafelyAsync) - without it, two displays reconnecting around the same time could race to open/claim the same USB devices. - _loopsByDeviceId and _backgroundImagePathsByDeviceId became ConcurrentDictionary: they're now written by N per-device supervisor tasks concurrently while WatchConfigForChangesAsync enumerates them on its own polling task, which a plain Dictionary doesn't allow safely. This is orchestration logic entirely inside the WPF App project, so - consistent with the rest of DisplayManagerHostedService - it has no new Linux-testable surface; the existing 104 tests are unaffected and still pass. Verified only by the Windows CI compile.
Adds a new Integrations window (tray menu: "Integrations...") for configuring external data sources entirely through the GUI - no config.json editing required, consistent with the rest of the app. Proxmox VE: - ProxmoxClient gains GetGuestStatusesAsync, listing every VM (qemu) and container (lxc) across all cluster nodes via the existing ticket-based auth. - ProxmoxGuestDirectory holds a copy-on-write snapshot of the latest guest list so the Widget Designer's stat picker can list live guests (CPU/Mem) without touching the network on the UI thread. - ProxmoxPumpService polls every 5s and re-authenticates automatically if a poll fails (e.g. ticket expiry). - WidgetDesignerWindow's stat dropdown now merges in one CPU and one Memory entry per discovered guest via BuildAvailableStatKeys(). Pi-hole: - New PiHoleClient/PiHoleStatsPublisher/PiHoleStatKeys, using the v5 token-based summaryRaw endpoint (not "summary" - that endpoint returns some fields as comma-formatted strings instead of numbers). - PiHolePumpService polls every 10s; no login/session needed. Both integrations share: - IntegrationConfig (extended with Realm for Proxmox) + SecretStore for the password/token, and IntegrationHttpClientFactory for building an HttpClient that pins a detected certificate rather than bypassing TLS validation wholesale. - TlsCertificateProbe: a trust-on-first-use helper that opens a throwaway TLS handshake to capture a host's certificate thumbprint so the user can confirm it before it's pinned, instead of typing a thumbprint in blind. Verified against a real loopback TLS server in TlsCertificateProbeTests, not just compiled. - IntegrationsWindow: independent Test & Save / Remove flows per integration; a saved password/token is never redisplayed, and leaving the field blank on save reuses the existing secret. Also fixes a concurrency gap in SecretStore: it was a plain Dictionary with no synchronization, which was fine while it only had one reader/writer, but the new pump services now read it concurrently while IntegrationsWindow can write to it. Added a lock-guarded _syncRoot around every dictionary access. 104 -> 114 tests passing on the cross-platform slnf. The WPF App project (IntegrationsWindow, TrayIconHostedService, HostFactory) still only builds on the Windows CI job.
…ndow Brightness: - DeviceProfileConfig.Brightness (0-7, default 7 - full, matching prior hardware behavior for existing configs with no saved value). - DeviceDisplayLoop.SetBrightnessAsync sends the hardware SetProperty(Brightness, ...) command directly (clamped to 0-7); this is a one-off property write, not part of the per-frame blit path. - DisplayManagerHostedService applies it on connect/reconnect and hot-reloads it on save, same pattern as the background image path. - Widget Designer toolbar gets a Brightness slider next to Background/Show Grid. UniFi: - Extends the UniFiClient/IUniFiClient scaffold that already existed in the repo (session login + CSRF token against a UniFi OS console) to parse actual numbers instead of just subsystem status strings: connected client count (num_user on the lan/wlan subsystems) and WAN throughput (rx/tx byte rates on the wan subsystem). - New UniFiStatKeys/UniFiStatsPublisher, following the same shape as Proxmox/Pi-hole's. - New UniFiPumpService (App): polls every 10s, re-logs-in automatically if a poll fails (expired session cookie/CSRF token), same shape as ProxmoxPumpService. - IntegrationConfig gains a Site field (UniFi controller site, e.g. "default"), alongside the existing Realm field Proxmox uses. Integrations window: - Converted from one long scrolling stack into a TabControl - Proxmox, Pi-hole, and UniFi each get their own tab instead of being stacked under separators. Also fixed while touching the Widget Designer's stat dropdown: Pi-hole's stat keys were added to RenderDataHub in an earlier milestone but were never added to WidgetCatalog.StatKeys, so there was no way to actually select a Pi-hole reading in the GUI. Added those alongside the new UniFi keys. 126 tests passing (up from 114), cross-platform build clean. The App project (brightness slider, UniFi tab, pump service, dropdown wiring) only builds on the Windows CI job.
The Windows build caught this (Linux only compiles the cross-platform slnf, which excludes the WPF App project): int.ToString() without an IFormatProvider is analyzer-flagged since its output can vary by the current user's locale. The brightness label is just a small integer 0-7, not locale-sensitive display data, so InvariantCulture is correct.
…igner Root cause of "additional screens never get detected, even after a restart": LibUsbAx206DeviceDiscovery derived each display's ID from its USB serial-number descriptor, but generic/clone AX206 panels commonly burn the same fixed placeholder string into every unit of a firmware batch instead of a real per-unit serial. Two panels reporting an identical serial collided onto the same DeviceId, so only one config profile was ever created no matter how many were plugged in. LibUsbAx206DeviceDiscovery now disambiguates any serial-number collision found within a scan by appending the USB port location. This does mean a disambiguated panel's identity is tied to its USB port - moving it to a different port looks like a new device - but that's an inherent limit of hardware with no real unique identifier, not something addressable in software. LibUsbAx206Transport.DeviceId is now settable (internal) so discovery can apply this after the initial probe, once every device on the scan is known. Also added a Refresh button next to the Widget Designer's device dropdown, so newly plugged-in displays (duplicate-serial or not) can be picked up without restarting the app: - DisplayManagerHostedService.RefreshDevicesAsync re-scans and starts supervising anything not already handled, auto-provisioning a default layout the same way StartAsync does at launch. - A new _supervisedDeviceIds set (distinct from _loopsByDeviceId, which only holds an entry while a loop is actively connected) tracks "has a supervisor task ever been spawned for this ID" for that task's whole lifetime, so a device mid-reconnect can't be mistaken for new and double-supervised. - StartAsync's config-change-watcher loop now always starts, even with zero devices found at launch, so a device adopted later via Refresh gets live hot-reload immediately instead of needing special-casing. - DisplayManagerHostedService is now also registered as an injectable singleton (previously only as IHostedService) so the Designer window can call it directly, same pattern already used for TrayIconHostedService. 120 tests passing, cross-platform build clean. Both changes are in Windows-only code paths (App project, and the libusb-backed Transport implementation) that can only be compile-verified on the Windows CI job.
Nothing previously let a stale device profile be cleared from config through the GUI - e.g. the leftover entry the serial-number collision fix in the last commit leaves behind for anyone who already had one auto-provisioned device before pulling that fix (a real case: two panels reporting the same firmware-burned serial used to collide onto one profile: "Display (20201115)"; after the fix they're correctly split into ".../20201115@6-17" and ".../20201115@6-18", but the old undisambiguated entry doesn't disappear on its own since nothing ever prunes config.Devices). Remove Device deletes the selected profile (layout, background, brightness) after a confirmation prompt, since a saved layout can't be recovered once gone. If that physical display is still plugged in, it reappears as a "new" device with a fresh default layout the next time it's discovered or Refreshed - config has no way to distinguish "unplugged for good" from "temporarily disconnected". App-project-only change (WidgetDesignerWindow), so it can only be compile-verified on the Windows CI job - no cross-platform files touched, so no change in the 120-test baseline.
Font sizes: replaced the Small/Medium/Large/Extra-Large presets with an explicit pixel size list (8-96px) plus "Auto (fit to box)", the prior default behavior. WidgetFontStyle gains FixedSizePixels - when set, WidgetTextRenderer uses it exactly and skips the width-shrink step (a fixed size should mean what it says; the compositor's existing per-widget clip keeps any overflow contained). The old relative "fontScale" setting is still read by WidgetFactory so layouts saved before this change keep rendering the same way; the designer just doesn't offer it as a choice anymore. Rename: a Rename button next to the device dropdown opens a small modal text prompt (WPF has no built-in input box) and saves DeviceProfileConfig.Name. Snap-to-align: dragging a widget now pulls its edges/center into line with the canvas edges, the canvas center, and other widgets' edges/ centers once within 6px, same idea as Canva/Figma alignment guides. The snap math (DesignerSnapEngine) lives in Ax206Display.Rendering so it's unit-testable without WPF; DesignerWidgetOverlay just calls it during a body drag (not resize) and the window draws a dashed guide line for whichever axis snapped, clearing it on mouse-up. 134 tests passing (up from 130), cross-platform build clean. The forge repo theming request is still blocked - repo access wasn't approved.
Repo access to forge wasn't available, but the user pasted the three files that actually define its look: tailwind.config.js, app.css, and app.html. Ported the dark-mode palette, Poppins font, and rounded-card shapes into a WPF ResourceDictionary (Theme/ForgeTheme.xaml), merged into Application.Resources so it cascades to every window - including ones built entirely in code-behind (most of this app's property panel and the Rename dialog), since WPF's implicit-style lookup walks up the type hierarchy with no explicit Style reference needed. Retemplated: Window, Button (two variants - a neutral default matching forge's .btn-secondary, and an explicit AccentButtonStyle applied to each window's one real call-to-action, matching .btn-primary), TextBox, PasswordBox, CheckBox, Slider, ComboBox + ComboBoxItem, ScrollBar, TabControl + TabItem, and ContextMenu + MenuItem (the tray icon's right-click menu). Two things this can't reach, called out in the theme file's own doc comment: MessageBox.Show and Microsoft.Win32.OpenFileDialog are native Win32 dialogs, not WPF windows - there's no supported way to skin either one. App-project-only change (all WPF XAML/resources), so it can only be compile-verified on the Windows CI job; the 130-test cross-platform baseline is unaffected.
The Windows build caught this (Linux only compiles the cross-platform slnf, which excludes the WPF App project): the theme file's top comment quoted CSS custom property names like --surface and --border-c, and XML doesn't allow '--' inside a comment body. Reworded to describe them without the literal token.
The theme mostly worked, but two things stayed white as shown in a screenshot: the toolbar strips in both windows, and the OS title bar. Toolbars: the unkeyed Style TargetType="Window" in ForgeTheme.xaml was relying on implicit-style resolution walking up from WidgetDesignerWindow/ IntegrationsWindow (both Window subclasses) to that base-type style - that didn't happen in practice, so the windows' own Background stayed the WPF default (white) even though every individual control inside them (Buttons, TextBoxes, ComboBoxes - all exactly type Button/TextBox/ ComboBox, no inheritance ambiguity) was already picking up its own implicit style correctly. That's exactly why individual elements were already dark/readable but the strips behind them weren't. Fixed by setting Background/Foreground/FontFamily explicitly on each Window and its root panel instead of trusting the inheritance to happen. Also added an explicit ScrollViewer style and a Background on the TabControl's content area for the same reason - anywhere a plain WPF-default background could still show through. Title bar: that's OS-drawn chrome, not WPF - no resource in ForgeTheme.xaml could ever reach it. Added DarkTitleBar.Apply(), a small DwmSetWindowAttribute P/Invoke (DWMWA_USE_IMMERSIVE_DARK_MODE), called from both real windows' constructors and the rename dialog. App-project-only change; 130-test cross-platform baseline unaffected.
The Windows build caught this: the analyzer flags an unused P/Invoke return value since ignoring an HRESULT can hide a real failure. Here it genuinely is fire-and-forget - an older Windows build without dark title bar support just fails the call and the title bar stays light, which needs no handling - so an explicit discard (_ = ...) is the correct fix, not the value being unused by oversight.
… clipping Save/Test & Save buttons: AccentButtonStyle no longer fills a solid lime block - it now inherits the plain grey button template and only overrides the text color to the same accent lime the brightness slider uses, matching every other button's look/hover/pressed behavior. Widget Designer toolbar: both rows were a horizontal StackPanel, which never wraps - on a narrower window its overflow (in practice, the Save button) silently extended past the visible window bounds with no way to reach it, rather than actually being visible anywhere. Switched to WrapPanel so extra buttons drop to a second line instead. App-project-only change; 130-test cross-platform baseline unaffected.
Pi-hole v6 replaced the old stateless per-request API token with a session login: POST /api/auth with an "app password" (a scoped credential from Settings -> API -> App Passwords) returns a session id (sid), sent as the X-FTL-SID header on later requests instead of a query-string token. The stats endpoint moved too: GET /api/stats/summary replaces /admin/api.php?summaryRaw, with queries.total/queries.blocked/ queries.percent_blocked instead of the old flat field names. The old v5 client just doesn't work against a v6 install - this wasn't a connection issue on its own. PiHoleClient: rewritten around LoginAsync (POST /api/auth, capture sid) + GetSummaryAsync (GET /api/stats/summary with the sid header) - PiHoleSummary drops its Status field along with it, since that came from a separate v6 endpoint (/api/dns/blocking) not worth a second poll call for what was only ever a cosmetic confirmation message. PiHolePumpService: restructured to log in once and reuse the session (re-authenticating only when a poll fails, e.g. an expired session), the same shape as ProxmoxPumpService/UniFiPumpService - previously it rebuilt a stateless client every 10s poll, which would have meant a fresh v6 login every 10s, wasteful and likely to trip v6's brute-force login throttling. Also split the Pi-hole tab's single free-text "Host URL" box into separate Host / Port / "Use HTTPS" fields: a user configuring this hit a bare "connection refused" trying the app's assumed default port before realizing their Pi-hole was actually reachable on a different port. An explicit Port field (defaulting to 80) makes that visible instead of buried inside a URL string the user has to get exactly right themselves. 132 tests passing (up from 130; +2 net new: a no-sid-in-response failure case and a call-before-login-throws case). Cross-platform build clean. The App project (IntegrationsWindow's new Host/Port/HTTPS fields, PiHolePumpService) only builds on the Windows CI job.
Root cause of "Failed: No such host is known. (http:80)": the Host field's own label says "no http:// or port", but typing a URL there is the natural instinct (that's what URLs normally look like), and BuildPiHoleBaseUrl() just concatenated "scheme://" + whatever was in the box + ":port" with no validation - "http://192.168.1.42" in the Host field plus the code's own "http://" produced "http://http://192.168.1.42:8080". .NET's Uri parser reads that as host "http" (stopping at the first ":"), and since nothing after it parses as a valid port, falls back to that scheme's default port 80 - hence a DNS failure on a host literally named "http", with no hint in the error of what actually went wrong. Added HostNormalizer (Ax206Display.DataSources.Http, cross-platform and unit-tested) that strips an accidental scheme/port/path/query from what's meant to be a bare hostname field, and wired it into both BuildPiHoleBaseUrl and the pre-save validation - the Host box is also now rewritten to the normalized value after Test & Save so the user can see what was actually used. 142 tests passing (up from 132; +10 for HostNormalizer). Cross-platform build clean. IntegrationsWindow.xaml.cs itself only builds on Windows.
…lliding-serial panels ProvisionDefaultProfileAsync was called with no try/catch in StartAsync's device loop; a transient USB failure there took the whole generic host down instead of just skipping that one display - the direct cause of a full-app crash seen in the field. Now logs a warning and skips the device, in both StartAsync and RefreshDevicesAsync. Also, DisambiguateDuplicateSerialNumbers only appended the disambiguating "@location" suffix to a serial that collided within the same discovery scan. If only one panel of a colliding pair re-enumerated after a drop (e.g. mid-reconnect), it came back with its bare serial, which matched neither supervisor's suffixed device ID and sat unmatched until the other panel happened to enumerate in the same scan. Extracted the ambiguity memory into a small testable AmbiguousSerialTracker that remembers a colliding serial for the life of the discovery instance, so a lone re-enumeration still gets disambiguated correctly.
…, add a gauge widget Widget Designer's "Reading" dropdown now groups every data key under a non-selectable category header (Local Device, Network, Pi-hole, UniFi, Proxmox) instead of one long flat list, via a WPF grouped ComboBox (ListCollectionView + PropertyGroupDescription). Exposed several numeric fields each integration's API already returns but never surfaced as a selectable reading: - Pi-hole (/api/stats/summary, already polled): domains on the blocklist, cached/forwarded query counts, unique domains, and active/total client counts. - UniFi: LAN and WLAN client counts split out from the combined total (the per-subsystem data was already being fetched). - Proxmox: per-node CPU%, memory%, and uptime - GetNodeStatusesAsync was already called to discover nodes before listing guests, but its numbers were discarded. Added ProxmoxNodeKeys/ProxmoxNodeDirectory mirroring the existing guest-level pattern. Added a new Gauge widget type: a compact 270-degree arc gauge with its own configurable color (independent of text color), min/max range, and the same font/label/unit/decimals controls as the stat widget. No background fill, so it composites over the device's background image like every other widget.
Converts the user-supplied 1024x1024 artwork (dark rounded square, lime accent circle, photo-frame glyph) into a multi-resolution .ico (16-256px) and wires it up via ApplicationIcon. This becomes both the taskbar/exe icon and the tray icon, since TrayIconHostedService pulls its icon from the running exe via Icon.ExtractAssociatedIcon.
The gauge widget's field count (Reading/Label/Unit/Decimals/Min/Max/ Gauge color/Text color/Font/Size) can exceed the fixed-height property panel, especially on a shorter window - the bottom fields were clipped with no way to reach them. Wrapped PropertyPanel in a ScrollViewer. (The gauge's label already sits under the value and auto-shrinks by measured text width via the shared WidgetTextRenderer - no change needed there.)
…ow it The value text and the label were both sized purely from the widget's rectangular height, with no awareness of the circular arc drawn behind them - on anything but a very generous box, the value's digits and the label both ended up overlapping the ring's stroke. Split the widget's height into two physically separate regions instead: a circular gauge area (arc + value) on top, and - only when a label is set - a footer strip entirely below it for the label. The two can never overlap by construction, since the label is drawn outside the circle's bounding box rather than into its bottom gap. The value itself is now confined to a square safely inside the ring (60% of its diameter, comfortably short of the ~70% an inscribed square would allow) and always shrinks to fit both dimensions - including an explicitly chosen size, via a new WidgetTextRenderer.alwaysShrinkToFitWidth flag, since letting a big chosen value size bleed past its box here means visibly crossing the ring around it. Added a dedicated "Value text size" control to the gauge's property panel, separate from the existing Font section's Size (which now sizes only the label, since it lives in its own plain rectangular strip like any other widget's text). Relabeled that shared control to "Label text size" specifically for the gauge to keep the two from being confused.
…ropdown scroll Added "Space Mono" to the font family list. Unlike the rest of the list, it isn't a Windows system font, so it'll only render as Space Mono on a machine that has it installed - SkiaSharp falls back gracefully to the default font otherwise, same as any other missing family name. Added a "Label distance" control to the gauge: labelGapPx pushes the label strip further from the ring (the ring's own area shrinks by the same amount so everything still fits within Height), defaulting to 0 to match the existing layout exactly for anyone who hasn't touched it. Fixed jumpy/rigid mouse-wheel scrolling in the Reading dropdown: the custom ComboBox template in ForgeTheme.xaml doesn't bind its nested ScrollViewer's CanContentScroll, so it was picking up the ComboBox default style's inherited "True" (line-by-line/item scrolling) - increasingly noticeable now that the grouped list has 20+ entries. Set CanContentScroll=false and disabled virtualization (no benefit at this list size, and one less way for grouping + scrolling to misbehave together) on that specific dropdown instance.
"Space Mono" was added to the font family list, but resolved through the same SKTypeface.FromFamilyName path as every other listed font - those are all pre-installed Windows system fonts, but Space Mono isn't, so on a machine without it separately installed, SkiaSharp silently substituted the default typeface. Selecting it in the designer visibly did nothing. Bundled the actual font (Regular/Bold/Italic/BoldItalic, SIL Open Font License 1.1, fonts/OFL.txt included) as embedded resources in Ax206Display.Rendering and special-cased it in WidgetTextRenderer.ResolveTypeface to load from there via SKTypeface.FromStream instead, cached per (bold, italic) pair so it's decoded once rather than on every frame. Now renders identically regardless of what's installed on the host machine - verified by rendering a sample to a PNG and confirming the actual Space Mono letterforms.
Small position tweaks previously required a mouse drag, which is hard to do precisely by exactly N pixels. Arrow keys now move the selected widget: 1px plain, 5px with Shift, 10px with Ctrl, 20px with Ctrl+Shift - four fixed step sizes reachable through the two modifier keys this window wasn't already using for anything else. Skipped while focus is on a TextBox, ComboBox, or the brightness Slider, since those already use the arrow keys for their own purpose (cursor movement, dropdown selection, value adjustment) - nudging would otherwise fight normal editing in the property panel. Reuses the same clamp-to-canvas-bounds logic as dragging, and updates the overlay via its existing SyncPosition() so the selection outline and position readout stay in sync exactly as they do during a drag.
Selecting a widget (by clicking it, or via the toolbar's Add buttons) never moved keyboard focus anywhere - it stayed wherever it last was, which in practice is almost always some ComboBox or TextBox left over from editing a previous widget's properties. OnWindowPreviewKeyDown's "don't hijack an editing control's arrow keys" check then matched every time, silently refusing to nudge anything regardless of what was actually selected on screen. OverlayCanvas is now focusable and gets keyboard focus on every selection, so arrow keys land on a neutral element instead of whatever property control was last touched. FocusVisualStyle is nulled out on it since the existing blue handle outline around the selected widget is already the selection indicator - a dashed rectangle around the entire canvas would just be visual noise.
Two panels sharing the same non-unique firmware serial only got their "@location" disambiguation applied when both were seen colliding in the same discovery scan, and that memory lived only in the in-process AmbiguousSerialTracker. On a full app/PC restart, if just one panel of the pair had finished USB enumeration by the very first scan (common at boot, when hubs power up a beat apart), it came back with its bare, undisambiguated serial. That bare ID matched neither saved "@location"-suffixed profile, so the host mistook the panel for a brand new display, auto-provisioned a fresh default (clock + CPU/RAM) layout, and drove that to the physical screen - while the old profile sat unchanged and unattached in config, still showing its previous content in the Widget Designer. Fix: seed the tracker at startup (and on every RefreshDevicesAsync) from any "{serial}@{location}"-shaped profile ID already in the saved config, so a lone panel is still correctly recognized as part of a known-colliding pair even on the very first post-restart scan.
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
mainwas significantly behind the actual app: it only contained an early M1 scaffold, while all of the real Widget Designer functionality (device dropdown, rename, brightness, integrations, reconnect handling, the duplicate-serial workaround, etc.) lived on the never-mergedclaude/ax206-display-tray-app-rjlvh4branch. This PR bringsmainup to that branch's tip, plus a fix for the reported bug on top.20201115). The app disambiguates colliding panels by appending their USB port location (20201115@6-17,20201115@6-18), but only once a collision is actually observed within a single discovery scan; that memory lived only in-process (AmbiguousSerialTracker). On a full app/PC restart, if just one panel of a colliding pair had finished USB enumeration by the time the very first scan ran (common at boot, when hubs power up a beat apart), it came back with its bare, undisambiguated serial. That bare ID matched neither saved@location-suffixed profile, so the host treated the panel as a brand-new display, auto-provisioned a fresh default clock/CPU/RAM layout, and drove that to the physical screen — while the old profile sat untouched and unattached inconfig.json, which is why the Widget Designer kept showing its previous content.AmbiguousSerialTracker's collision memory at startup (and on every manual refresh) from any{serial}@{location}-shaped profile ID already present in the saved config, so a lone panel is still correctly recognized as part of a known-colliding pair even on the very first post-restart scan — closing the restart race instead of only handling the mid-session reconnect case.Changes
IAx206DeviceDiscovery: newSeedKnownAmbiguousSerialNumbersmember (default no-op for implementations that don't disambiguate by serial collision).LibUsbAx206DeviceDiscovery: implements it by delegating toAmbiguousSerialTracker.AmbiguousSerialTracker: newSeedmethod to prime the collision memory from an external source.DisplayManagerHostedService: seeds known-ambiguous serials from saved config profiles before every discovery scan in bothStartAsyncandRefreshDevicesAsync.Seedbehavior.Test plan
AmbiguousSerialTrackerTestsplus two new cases covering seeding).Generated by Claude Code