diff --git a/.planning/STATE.md b/.planning/STATE.md index 86248fde..de0fdb67 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -27,7 +27,7 @@ Phase: 1040 Plan: Not started Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. Status: Phase complete — ready for verification -Last activity: 2026-06-02 +Last activity: 2026-06-02 - Completed quick task 260602-mri: crosshair-link toggle on the FastSense widget bar (mirrors the hover crosshair across active-page FastSense widgets) ### Note on parallel v4.0 work (main branch state) @@ -96,6 +96,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict | 260526-pqz | Raise per-signal slider-preview cap from 400 → 1000 buckets in `DashboardEngine.computePreviewEnvelopeReturning_` — three textual edits (1 code clamp + 2 documenting comments) in `libs/Dashboard/DashboardEngine.m` plus one consistency comment in `tests/test_dashboard_preview_overlay.m` (no assertion change; `numel(xd) >= 4` is cap-independent). Edit sites: line 3524 doc-comment (`computePreviewEnvelope` range), line 3542 inline comment (clamp range), line 3555 actual clamp `max(50, min(1000, floor(axWpx / 2)))`. Out of scope per plan: cache invalidation of `PreviewNBuckets_` — running demos must restart (or trigger the existing resize-invalidation path at `DashboardEngine.m:2241`) for the new cap to take effect. Static analysis clean: `mh_lint` + `mh_style` on both edited files report "everything seems fine"; regression sweep `grep -rn "\b400\b" tests/ \| grep -iE "(preview\|bucket\|envelope)"` returns no matches. MATLAB R2025a: `test_dashboard_preview_envelope` 7/7, `test_dashboard_preview_overlay` 10/10. Octave 11.1.0: `test_dashboard_preview_envelope` 2/2 (5 skipped — pre-existing TimeRangeSelector guard for patch+FaceAlpha+NaN on xvfb), `test_dashboard_preview_overlay` skipped entirely (pre-existing). | 2026-05-26 | 834b43c | — | [260526-pqz-raise-preview-line-cap-per-signal-from-4](./quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/) | | 260529-rxf | Real per-event email alerts for background monitoring — new `EmailTransport` (SMTP auth/STARTTLS:587 default, also `none`/`ssl`; Octave `exist('sendmail','file')` log-and-skip guard; pure static `buildMailProps` CI seam) that `NotificationService` now delegates to via an injectable `Transport` property; per-(sensor,threshold) email cooldown (default 5 min, 0 disables; dry-run honors it too) with public `SuppressedCount`; `LiveEventPipeline.processMonitorTag_`/`runCycle` now forward real per-event `sensorData` (X/Y/thresholdValue/thresholdDirection from the live tick) so `IncludeSnapshot` rules attach PNGs in live mode. MATLAB-only per user decision. **Backward-compat preserved**: pipeline still defaults to `NotificationService('DryRun', true)` and all prior tests stay green. Verified locally (R2025a, live MATLAB MCP): `test_email_transport` 5/5, `test_notification_service` 10/10 (7 original + 3 new: delegation / cooldown-suppress / cooldown-expiry-via-Hidden-DI-seam), `test_live_event_pipeline_tag` 3/3, plus class suites `TestEmailTransport` 5/5, `TestNotificationService` 7/7, `TestLiveEventPipelineTag` 3/3. MISS_HIT (`mh_style`+`mh_lint`) clean on all 8 files; MATLAB Code Analyzer clean on the 3 new/edited libs. Real SMTP delivery is the single manual step via `examples/05-events/smoke_email_send.m` (FASTSENSE_SMTP_* env vars, STARTTLS:587), out of CI. | 2026-05-29 | 203da7a, 2ac6887, 341bab2, cef1fc5 | Verified | [260529-rxf-real-per-event-email-alerts-for-backgrou](./quick/260529-rxf-real-per-event-email-alerts-for-backgrou/) | | 260529-fnt | Add `FunctionTransport` adapter (`libs/EventDetection/FunctionTransport.m`) — wraps a user-supplied function handle as a `NotificationService` `Transport` so an existing site/company MATLAB mailer can be reused for alerts with **no SMTP config** (no server/port/creds, no Gmail App Password). Drop-in duck-typed `send(recipients,subject,body,attachments)` (same as EmailTransport), normalizes recipients to a flat cellstr, defaults attachments to `{}`, Octave-safe (only calls user code). Purely additive — EmailTransport/NotificationService behavior unchanged. Built via **/gsd:fast** (inline, no subagents). Verified (R2025a): `test_function_transport` 5/5 (forwarding / recipients-normalization / attachments-default / invalid-handle / NotificationService integration), `test_notification_service` 10/10 (no regression); MISS_HIT + Code Analyzer clean on all touched files. `example_live_pipeline.m` gains a commented FunctionTransport option. Follow-up to 260529-rxf after the user opted to reuse their company mailer instead of configuring Gmail SMTP. | 2026-05-29 | 706e9d5 | Verified | (inline) | +| 260602-mri | Add a crosshair-link toggle ('X') to the FastSenseWidget grey WidgetButtonBar that mirrors the hover crosshair across all FastSense widgets on the **active dashboard page**. New `CrosshairLinked` public property + `setCrosshairLink(tf)` on `FastSenseWidget` (default OFF; `toStruct` omits when false so legacy serialized dashboards are byte-identical; `fromStruct` restores). `HoverCrosshair` gains `setBroadcastFcn`/`onMoveExternal`/`onLeaveExternal` + a **deterministic `IsMirrored_` suppress flag** so a mirrored peer's same-dispatch self-leave is a no-op — **ZERO new figure-WBM closures**, respecting the 260512-egv/eu2 chained-WBM constraint; the broadcast rides on existing per-crosshair `onMove`/`onLeave` and each peer computes its OWN per-series datatip at the shared data-x via `computeYAtX_` (raw-x, no Y transmitted). `DashboardEngine` derives the active-page link set on demand (`collectLinkedCrosshairs_`, flattens GroupWidget children via `flattenWidgetsForPreview_`) through `broadcastCrosshairX_`/`broadcastCrosshairLeave_`/`onCrosshairLinkToggle`, and re-primes broadcast hooks via `rewireCrosshairLinks_` after `rerenderWidgets`/`switchPage`/`detachWidget`. Duck-typed 'X' button injected in `DashboardLayout.realizeWidget` via `ismethod(widget,'setCrosshairLink')` (leftmost chrome button, left of V/A), re-anchored by `reflowChrome_`, protected in `DashboardWidget.clearPanelControls`. **Orchestrator verification (R2025a, live MCP)** replaced the executor's flaky `tic`-window suppress (`SuppressLeaveUntil_`/`SuppressWindow_` — passed in isolation, failed in-suite) with the deterministic `IsMirrored_` flag, and fixed a **latent unbounded leave ping-pong** (`onLeaveExternal` now hides directly via a private `hideGraphics_` instead of re-entering broadcasting `onLeave`). Tests: new `test_fastsense_crosshair_link` **11/11**; regressions green (`test_hover_crosshair` 11/11, `test_fastsense_widget_ylimit_modes` 11/11, `test_time_range_selector_reinstall_after_rerender` pass, `test_dashboard_time_sync_all_pages` 5/5); MISS_HIT clean on all 6 files; Code Analyzer no new findings; live UI smoke on a rendered 2-widget dashboard (button renders + toggles, hovering one linked widget mirrors crosshair+datatip on the other, leave hides both, unlink stops mirroring). DetachedMirror crosshair-link parity OUT OF SCOPE (detached widgets use a figure-level `FastSenseToolbar`, not a WidgetButtonBar — matches 260513-sfp). | 2026-06-02 | a495cbc9, 635632e2, 8950abd5, 485154b7 | Verified | [260602-mri-add-crosshair-link-toggle-to-fastsense-w](./quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/) | ## Progress Bar diff --git a/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-PLAN.md b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-PLAN.md new file mode 100644 index 00000000..a2454b0c --- /dev/null +++ b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-PLAN.md @@ -0,0 +1,291 @@ +--- +phase: 260602-mri +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - libs/FastSense/HoverCrosshair.m + - libs/Dashboard/FastSenseWidget.m + - libs/Dashboard/DashboardEngine.m + - libs/Dashboard/DashboardLayout.m + - libs/Dashboard/DashboardWidget.m + - tests/test_fastsense_crosshair_link.m +autonomous: true +requirements: + - MRI-01 # CrosshairLinked property + setCrosshairLink on FastSenseWidget (default false), toStruct/fromStruct round-trip (omit default) + - MRI-02 # HoverCrosshair gains BroadcastFcn_ broadcast hook + suppress-leave guard so a mirrored crosshair shows-instead-of-hides without new figure-WBM closures + - MRI-03 # DashboardEngine coordinates the active-page link set: hovered widget broadcasts its data-x; every OTHER linked FastSense widget mirrors crosshair + per-series datatip at that x + - MRI-04 # Crosshair-link toggle button (X glyph) on the FastSenseWidget WidgetButtonBar, duck-typed via ismethod(widget,'setCrosshairLink'); reflow re-anchors it; survives Reset/resize/page-switch + - MRI-05 # Backward compat: default OFF, legacy serialized dashboards load unchanged; unlink-on-detach; per-active-page scope + +must_haves: + truths: + - "A FastSenseWidget tile shows a crosshair-link toggle button (X) on its grey WidgetButtonBar, left of V/A and Info/Detach" + - "Toggling the button ON for >=2 FastSense widgets on the active page links them: hovering ANY linked widget mirrors the crosshair x onto all OTHER linked widgets, each showing its own per-series datatip at that x" + - "Toggling the button OFF removes that widget from the link set; it no longer mirrors or receives" + - "When the cursor leaves all linked axes, the mirrored crosshairs hide" + - "Existing single-widget hover (standalone FastSense, unlinked dashboard widgets) is unchanged" + - "A legacy serialized dashboard (no crosshairLinked field) loads with all widgets unlinked and identical behaviour + identical JSON" + - "Linking survives top-toolbar Reset (rerenderWidgets), figure resize, and page switch" + artifacts: + - path: "libs/FastSense/HoverCrosshair.m" + provides: "BroadcastFcn_ property + setBroadcastFcn(fn) + suppress-leave guard (SuppressLeaveUntil_/onMoveExternal)" + contains: "BroadcastFcn_" + - path: "libs/Dashboard/FastSenseWidget.m" + provides: "CrosshairLinked public property (default false) + setCrosshairLink(tf) + toStruct/fromStruct round-trip (omit when false)" + contains: "CrosshairLinked" + - path: "libs/Dashboard/DashboardEngine.m" + provides: "Active-page crosshair-link coordination: onCrosshairLinkToggle, broadcastCrosshairX_, broadcastCrosshairLeave_, collectLinkedCrosshairs_, rewireCrosshairLinks_" + contains: "collectLinkedCrosshairs_" + - path: "libs/Dashboard/DashboardLayout.m" + provides: "addCrosshairLinkToggle button (Tag CrosshairLinkButton) duck-typed via ismethod(widget,'setCrosshairLink'); reflowChrome_ re-anchors it" + contains: "CrosshairLinkButton" + - path: "tests/test_fastsense_crosshair_link.m" + provides: "Pure-logic + headless-render unit tests for property round-trip + collectLinkedCrosshairs_ + suppress-leave guard" + contains: "test_fastsense_crosshair_link" + key_links: + - from: "libs/Dashboard/DashboardLayout.m (CrosshairLinkButton callback)" + to: "DashboardEngine.onCrosshairLinkToggle (via EngineRef)" + via: "button callback -> widget.setCrosshairLink(tf) + engine.onCrosshairLinkToggle(widget)" + pattern: "onCrosshairLinkToggle" + - from: "libs/FastSense/HoverCrosshair.m (source onMove)" + to: "DashboardEngine.broadcastCrosshairX_" + via: "BroadcastFcn_ callback fired at end of onMove" + pattern: "BroadcastFcn_" + - from: "DashboardEngine.broadcastCrosshairX_" + to: "peer HoverCrosshair_.onMoveExternal(x)" + via: "loop over collectLinkedCrosshairs_(activePageWidgets) excluding source" + pattern: "onMoveExternal" + - from: "DashboardEngine.rerenderWidgets / switchPage" + to: "rewireCrosshairLinks_" + via: "re-prime BroadcastFcn_ on freshly-rebuilt HoverCrosshair_ handles" + pattern: "rewireCrosshairLinks_" +--- + + +Add a crosshair-link toggle button to the FastSense dashboard widget's grey WidgetButtonBar. When toggled ON for a widget, that widget joins a crosshair-link set scoped to the CURRENTLY ACTIVE dashboard page. Moving the hover crosshair over ANY linked FastSense widget mirrors the crosshair's data-x onto all OTHER linked FastSense widgets on the same page; each mirrored widget shows its own per-series datatip at that same x — so the user compares values at the same x/time across multiple plots. Toggling OFF removes the widget from the set. + +Purpose: Cross-plot value comparison at a shared x/time without detaching widgets or losing dashboard context. +Output: A new `CrosshairLinked` property + button, a broadcast hook on `HoverCrosshair`, and active-page link coordination on `DashboardEngine` — all backward-compatible, MATLAB+Octave-safe, toolbox-free. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@CLAUDE.md +@.planning/STATE.md + +# Precedent — the EXACT pattern to mirror (V/A/L buttons): widget exposes a setter, +# DashboardLayout duck-types via ismethod, toStruct omits the default. +@.planning/quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/260513-sfp-SUMMARY.md + + +User chose plain /gsd:quick (no --discuss). The planner locked these defaults — record them in SUMMARY: + +- **D-01 Link model — SHARED SET.** Any widget with the toggle ON both broadcasts on hover AND receives mirrors. No master/source role. Matches "mirror across ALL other FastSense widgets". +- **D-02 X mapping — RAW DATA-X.** Sensor dashboards share a time axis and the user said "same x/time position". `HoverCrosshair.onMove(xQuery)` already computes each line's Y at xQuery via `computeYAtX_` (binary_search), so each mirrored widget shows ITS OWN series values for free — no Y is transmitted. Log-x axes: the raw x VALUE is still correct (no pixel/normalized transform); a peer whose data does not span x shows em-dashes, which is the existing OOR behaviour. No special-casing needed. +- **D-03 Coordination owner — DashboardEngine methods + per-widget flag (NO new class).** The link set is derived on demand from `FastSenseWidget.CrosshairLinked` over the flattened active-page widgets (`collectLinkedCrosshairs_`). Least-invasive; the enumeration logic is a pure-testable helper. +- **D-04 Suppress-hide crux — broadcast hook + suppress-leave tic-guard on HoverCrosshair; NO new figure-WBM closures.** The single dashboard-figure WindowButtonMotionFcn already carries a chain of every widget's `HoverCrosshair.onFigureMove_` (newHcN -> ... -> newHc1 -> trs). When the cursor is over widget A, A's `onFigureMove_` resolves "inside" and calls `A.onMove(xQuery)`; B/C/D's `onFigureMove_` resolve "outside" and call `onLeave()` (hide). We make A's `onMove` ALSO drive peers, and make a recently-mirrored peer's `onLeave()` a no-op: + 1. New `BroadcastFcn_` callback on HoverCrosshair, fired at the END of `onMove(xQuery)` (only when set). On a dashboard, the engine sets this to `@(x) engine.broadcastCrosshairX_(thisCrosshair, x)`. + 2. `broadcastCrosshairX_(sourceHc, x)` loops the active-page linked crosshairs, and for each peer != source calls `peer.onMoveExternal(x)`. + 3. `onMoveExternal(x)` sets `SuppressLeaveUntil_ = tic` (window ~= 3*ThrottleSeconds) then calls `onMove(x)` with broadcasting DISABLED (re-entrancy: peers must not re-broadcast). `onLeave()` early-returns while `toc(SuppressLeaveUntil_) < window`. So when peer B's own `onFigureMove_` later fires `onLeave()` in the SAME motion dispatch (cursor not over B), the guard swallows it and B's mirrored crosshair stays visible. + 4. When the cursor leaves ALL linked axes: the source's `onFigureMove_` calls its own `onLeave()` (no suppress on the source) and, if `BroadcastFcn_` is set, the engine also broadcasts `onLeaveExternal()` to peers (clears their suppress + hides). Implement leave-broadcast by having `onLeave()` on a broadcasting crosshair invoke a paired `LeaveBroadcastFcn_` (or fold leave into `BroadcastFcn_` with a sentinel — simplest: a second callback `BroadcastLeaveFcn_`). Peers' `onLeaveExternal()` sets `SuppressLeaveUntil_ = []` then hides. + This adds ZERO new closures to the figure WBM — the broadcast rides on the EXISTING per-crosshair `onMove`/`onLeave` calls. This is the design constraint that prevents repeating the 260512-egv dangling-closure regression. +- **D-05 Lifecycle — per-active-page set; unlink-on-detach; re-prime after rerender/switchPage.** Each `FastSenseWidget`'s `HoverCrosshair_` is recreated by `FastSense.render()` on every `rerenderWidgets` (Reset) and on page allocation. The engine must RE-PRIME `BroadcastFcn_`/`BroadcastLeaveFcn_` on the fresh crosshair handles after the realizeWidget loop and after switchPage (the 260512-eu2 reinstall-after-rerender lesson). Detaching a linked widget: drop it from the set by leaving `CrosshairLinked=true` but it no longer participates because `collectLinkedCrosshairs_` only walks active-page widgets — the detached widget is no longer in `activePageWidgets()`. Simpler still: clear its broadcast hook on detach. Mirrored crosshairs hide naturally on the next source `onLeave`. +- **D-06 Backward compat — default OFF, toStruct omits, legacy loads unchanged.** Mirror the YLimitMode idiom byte-for-byte. +- **D-07 Button glyph — ASCII 'X'.** Matches existing ASCII Info ('i') / Detach ('^') / V / A / L glyphs (Octave Unicode-in-uicontrol rendering is inconsistent; the WidgetButtonBar deliberately avoids Unicode — see 260513-sfp D-03). Active (linked) state highlighted via the existing `chooseYLimitActiveBg_` helper. + + + + + +From libs/FastSense/HoverCrosshair.m (verified): +- `properties (SetAccess = private)`: Target, hFigure, hAxes, hLineV, hTipBox (GetAccess public — tests read fp.HoverCrosshair_) +- `properties (Access = private)`: PrevWBMFcn_, LastUpdateTime, IsBusy, FigDeleteListener, AxDeleteListener, ThrottleSeconds=0.025 +- `onMove(obj, xQuery)` — PUBLIC. Updates+shows the vertical line + datatip at data-x xQuery. Computes per-line Y at xQuery internally (computeYAtX_). THIS is the external-drive entry point. +- `onLeave(obj)` — PUBLIC. Hides line + datatip. Already guarded by `if ~isvalid(obj); return; end`. +- `onFigureMove_(obj, src, evt)` — PRIVATE chained WBM handler. Lines 295-297: validity/handle guards FIRST. Line ~342-346: when cursor is OUTSIDE hAxes pixel bounds it calls `obj.onLeave()`. Line 366: when inside, `obj.onMove(xQuery)`. +- `delete(obj)` restores PrevWBMFcn_ unconditionally. + +From libs/FastSense/FastSense.m (verified): +- `HoverCrosshair` PUBLIC property (line 91, default true) — opt-out flag. +- `HoverCrosshair_` property, `SetAccess = private` (line 174) -> GetAccess is PUBLIC. Created in render() at lines 1622-1631 (only when interactive desktop). So from a widget: `widget.FastSenseObj.HoverCrosshair_` is the reachable per-widget crosshair handle (may be [] under -batch/headless — guard with isempty + isvalid). + +From libs/Dashboard/FastSenseWidget.m (verified — mirror the YLimitMode pattern exactly): +- `properties (Access = public)`: ... LiveViewMode='preserve', YLimitMode='auto-visible' (line 66). ADD `CrosshairLinked = false` here. +- `FastSenseObj` (SetAccess=private, line 71): the FastSense handle (.hAxes, .IsRendered, .HoverCrosshair_). +- `setYLimitMode(obj, mode)` (line 545) — public setter precedent. +- toStruct (line 1194): `if ~strcmp(obj.YLimitMode,'auto-visible'); s.yLimitMode = obj.YLimitMode; end` (line 1205-1207). ADD `if obj.CrosshairLinked; s.crosshairLinked = true; end`. +- fromStruct (line 1501): `if isfield(s,'yLimitMode'); try obj.setYLimitMode(s.yLimitMode); catch; end; end` (line 1566-1572). ADD analogous `if isfield(s,'crosshairLinked'); obj.CrosshairLinked = logical(s.crosshairLinked); end` (do NOT call a setter that touches graphics here — fromStruct runs pre-render; just set the flag, the engine wires broadcast at render/realize time). + +From libs/Dashboard/DashboardLayout.m (verified): +- `EngineRef` property (line 28) — back-reference to DashboardEngine; set at DashboardEngine.m:164 `obj.Layout.EngineRef = obj;`. +- `realizeWidget` (line ~352-428): chrome injected when `needsBar`. `needsBar` (line 370-373) already includes `ismethod(widget,'setYLimitMode')`. The crosshair-link button is FastSenseWidget-only and FastSenseWidget already triggers needsBar via setYLimitMode, so NO needsBar change is required — but the new button injection block must be added INSIDE the `if needsBar` body, after `addYLimitButtons_` (line 401-403) and after `addPlantLogToggle` (line 405-413), BEFORE the final `reflowChrome_(widget.hCellPanel, 28, 2)` (line 420). +- Button precedent to copy: `addPlantLogToggle(obj, widget, engine)` (line 627) — reaches engine via EngineRef, idempotent (deletes prior by Tag), builds uicontrol with a Tag, callback toggles state + rebuilds + reflows. `onPlantLogTogglePressed_` (line 723) wraps in try/catch + non-blocking uialert. +- `addYLimitButton_` (line 988) — the simplest uicontrol-button template (Position [x 2 24 24], FontSize 9, FontWeight bold, theme.ToolbarFontColor / theme.ToolbarBackground, Tag, TooltipString, Callback). +- `reflowChrome_(hCell, barH, inset)` STATIC (line 1068) — re-anchors right cluster. Right-to-left order today: Detach (barW-24-4) ... Create ... Info ... PlantLog (xPl) ... then V/A cluster LEFT of PlantLog (xAll/xVisible at lines 1130-1146). ADD CrosshairLink as the LEFTMOST button: place it to the LEFT of the V/A cluster (i.e. `xLink = xVisible - gap - bw`). Find it via `findobj(bar(1),'Tag','CrosshairLinkButton','-depth',1)` and set its Position. Keep the same bw=24, gap=4 convention. +- `chooseYLimitActiveBg_(theme)` STATIC (line 1155) — reuse for the linked (active) highlight. + +From libs/Dashboard/DashboardWidget.m (verified): +- `clearPanelControls(hPanel)` (line 133): `protectedTags` (line 145-147) lists InfoIconButton, DetachButton, WidgetButtonBar, YLimitVisibleBtn, YLimitAllBtn, CreateEventButton, PlantLogToggleButton. ADD `'CrosshairLinkButton'` so the toggle survives re-render sweeps. + +From libs/Dashboard/DashboardEngine.m (verified): +- `Layout.EngineRef = obj` set at line 164. +- `activePageWidgets()` (line 2834) and `allPageWidgets()` (line 2845) — PUBLIC. +- `flattenWidgetsForPreview_(obj, widgets, depth)` (line 3390) — depth-first flatten that unwraps GroupWidget children via getNestedWidgets(); depth cap 10. USE THIS to flatten active-page widgets so FastSense widgets nested in a GroupWidget participate. THIS feature is ACTIVE-PAGE scoped -> `flattenWidgetsForPreview_(activePageWidgets())`. +- `rerenderWidgets` (lines 1742-1805): deletes each widget's outer cell panel (1742-1769), reinstalls TRS callbacks (1782-1790), re-allocates + `realizeWidget` loop (1794-1799 — this recreates each FastSense + a FRESH HoverCrosshair_), re-wires DetachCallback (1802) + CreateEventCallback (1804). ADD a `rewireCrosshairLinks_()` call at the END of this method (after line 1804) so the fresh HoverCrosshair_ handles get their BroadcastFcn_ re-primed. +- `switchPage(obj, pageIdx)` (line 243) — after the page becomes active, call `rewireCrosshairLinks_()` so the now-active page's linked widgets are wired (and the previously-active page's are dropped, since collectLinkedCrosshairs_ only walks the active page). +- `detachWidget(obj, widget)` (line 1535) — on detach, clear that widget's crosshair broadcast hook (best-effort) so a detached widget stops participating. + + + + + + + Task 1: CrosshairLinked property + HoverCrosshair broadcast hook (pure-testable core) + libs/Dashboard/FastSenseWidget.m, libs/FastSense/HoverCrosshair.m, tests/test_fastsense_crosshair_link.m + + FastSenseWidget (per MRI-01, mirror YLimitMode idiom): + - New public property `CrosshairLinked = false` (declare in the `properties (Access = public)` block beside YLimitMode at line 66, with a header comment). + - New public method `setCrosshairLink(obj, tf)`: validate `logical scalar` (accept logical or 0/1 numeric scalar; else error 'FastSenseWidget:invalidCrosshairLink'); set `obj.CrosshairLinked = logical(tf)`. Do NOT touch graphics here — the engine owns broadcast wiring. (Setter is the duck-type hook DashboardLayout checks via ismethod.) + - toStruct (line ~1205): after the yLimitMode block, add `if obj.CrosshairLinked; s.crosshairLinked = true; end` (omit when false -> legacy JSON byte-identical). + - fromStruct (line ~1566): after the yLimitMode block, add `if isfield(s,'crosshairLinked'); obj.CrosshairLinked = logical(s.crosshairLinked); end`. Absent -> stays false. + + HoverCrosshair (per MRI-02, the suppress-hide mechanism — D-04): + - Add `properties (Access = private)`: `BroadcastFcn_ = []`, `BroadcastLeaveFcn_ = []`, `SuppressLeaveUntil_ = []`, `SuppressWindow_ = 0.075` (~3*ThrottleSeconds). + - New public `setBroadcastFcn(obj, moveFn, leaveFn)`: store the two callbacks (each a function_handle or []). Tolerate nargin<3 (leaveFn defaults []). + - New public `onMoveExternal(obj, xQuery)`: guard `if ~isvalid(obj); return; end`; set `obj.SuppressLeaveUntil_ = tic`; then drive the crosshair WITHOUT re-broadcasting — call the existing `onMove` body but suppress the broadcast tail (simplest: set a private `InBroadcast_=true` flag, call `obj.onMove(xQuery)`, reset flag; `onMove`'s broadcast tail is gated on `~obj.InBroadcast_`). Add `InBroadcast_ = false` to the private props. + - New public `onLeaveExternal(obj)`: guard isvalid; set `obj.SuppressLeaveUntil_ = []`; call `obj.onLeave()` (which will now hide since the guard is cleared). + - Modify `onMove(obj, xQuery)`: at the very END (after the tip box is shown), add the broadcast tail: + `if ~obj.InBroadcast_ && isa(obj.BroadcastFcn_,'function_handle'); try obj.BroadcastFcn_(xQuery); catch; end; end` + - Modify `onLeave(obj)`: at the TOP, add the suppress guard: + `if ~isempty(obj.SuppressLeaveUntil_); try; if toc(obj.SuppressLeaveUntil_) < obj.SuppressWindow_; return; end; catch; obj.SuppressLeaveUntil_ = []; end; end` + AND at the END (after hiding), if this crosshair is a broadcaster (not currently being externally driven) and has a leave callback, broadcast leave: + `if ~obj.InBroadcast_ && isempty(obj.SuppressLeaveUntil_) && isa(obj.BroadcastLeaveFcn_,'function_handle'); try obj.BroadcastLeaveFcn_(); catch; end; end` + NOTE on ordering: the source crosshair has `SuppressLeaveUntil_` empty (it is the hovered one), so its onLeave runs the hide + leave-broadcast. A peer being mirrored has `SuppressLeaveUntil_` set, so its own onLeave (fired by its own onFigureMove_ later in the same dispatch) early-returns. This is the crux — verify carefully. + - delete(obj): also clear the callbacks (set BroadcastFcn_/BroadcastLeaveFcn_ to []) before/after restoring PrevWBMFcn_ (defensive — avoid a dangling engine ref firing post-delete; the existing isvalid guards already protect, but null the handles too). + + Tests (tests/test_fastsense_crosshair_link.m — function-style, mirror test_fastsense_widget_ylimit_modes.m structure: addpath+install, nPassed/nFailed counters, per-case try/catch, em-dash-free messages, a canRenderFigures_() helper, final `assert(nFailed==0)` + `fprintf(' All %d tests passed.\n', nPassed)`): + - PURE (no figure): default `CrosshairLinked == false`. + - PURE: setCrosshairLink(true) -> true; setCrosshairLink(false) -> false; setCrosshairLink('bad') throws 'FastSenseWidget:invalidCrosshairLink'. + - PURE: toStruct omits crosshairLinked when false (`~isfield(toStruct,'crosshairLinked')`); emits `true` when set. + - PURE: fromStruct restores true when present; legacy struct (no field) -> false. + - RENDER-GUARDED (canRenderFigures_): build a FastSense on an offscreen axes, attach a HoverCrosshair, call `setBroadcastFcn`; assert `onMoveExternal(x)` makes the crosshair line Visible='on' AND that an immediately-following `onLeave()` is SUPPRESSED (line still Visible='on') because SuppressLeaveUntil_ is fresh; then `onLeaveExternal()` hides it (Visible='off'). This is the direct unit proof of the suppress-leave crux. + - RENDER-GUARDED: a broadcaster's `onMove(x)` invokes BroadcastFcn_ exactly once (capture via a counter closure); `onMoveExternal(x)` does NOT re-invoke it (InBroadcast_ gate). + + + Implement FastSenseWidget property/setter/round-trip first (RED: write the PURE test cases, watch them fail, then GREEN). Then implement the HoverCrosshair broadcast hook + suppress-leave guard (RED render-guarded cases, then GREEN). Keep all new error IDs namespaced. Octave-safe: HoverCrosshair already documents it is MATLAB-only for the render path (test_hover_crosshair skips on Octave) — gate the render cases behind canRenderFigures_() and an Octave skip exactly like test_hover_crosshair.m. The PURE FastSenseWidget cases MUST run on both MATLAB and Octave (no graphics). + Follow MISS_HIT style: 160-col lines, 4-space tabs, PascalCase props, camelCase methods, trailing-underscore private state. + + + MISSING — orchestrator runs live MATLAB. Executor cannot run MATLAB (no MCP). Static check: `grep -n "CrosshairLinked" libs/Dashboard/FastSenseWidget.m` shows property + toStruct + fromStruct; `grep -n "BroadcastFcn_\|onMoveExternal\|onLeaveExternal\|SuppressLeaveUntil_" libs/FastSense/HoverCrosshair.m` shows all four; `grep -c "nPassed = nPassed + 1" tests/test_fastsense_crosshair_link.m` >= 8. Orchestrator will run: `mcp__matlab__run_matlab_test_file tests/test_fastsense_crosshair_link.m` (expect all pass) + `tests/test_fastsense_widget_ylimit_modes.m` + `tests/test_hover_crosshair.m` as regression. + + FastSenseWidget has CrosshairLinked (default false) + setCrosshairLink + JSON round-trip (omit default). HoverCrosshair has BroadcastFcn_/BroadcastLeaveFcn_ + setBroadcastFcn + onMoveExternal + onLeaveExternal + suppress-leave guard, with onMove broadcasting once at its tail and onLeave honoring the suppress window. test_fastsense_crosshair_link.m exists with >=8 cases (PURE + render-guarded suppress-leave proof). No new figure-WBM closures introduced. + + + + Task 2: DashboardEngine active-page link coordination + lifecycle re-wiring + libs/Dashboard/DashboardEngine.m, tests/test_fastsense_crosshair_link.m + + Implement the coordination layer (MRI-03, MRI-05) on DashboardEngine. Add a `methods` block (public where tests need it): + + 1. `collectLinkedCrosshairs_(obj, widgets)` — PURE-ENUMERATION helper (make it public or Hidden so the test can drive it with a synthetic widget list). Flatten `widgets` via `obj.flattenWidgetsForPreview_(widgets)` (unwraps GroupWidget children). Return a cell array of structs `{struct('widget',w,'hc',hc), ...}` for every flattened widget that (a) `isa(w,'FastSenseWidget')`, (b) `w.CrosshairLinked` is true, (c) `~isempty(w.FastSenseObj)` and the FastSense is rendered, (d) `~isempty(w.FastSenseObj.HoverCrosshair_)` and `isvalid` (Octave: skip isvalid). Skip any widget failing a guard — never throw. KEEP THIS PURE (no side effects) so it is unit-testable against a hand-built widget list. + + 2. `rewireCrosshairLinks_(obj)` — the wiring driver. Compute `linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets())`. FIRST clear BroadcastFcn_ on ALL active-page FastSense crosshairs (so a widget toggled OFF, or the previously-active page, stops broadcasting) — walk the flattened active page, and for each FastSenseWidget with a valid HoverCrosshair_ call `hc.setBroadcastFcn([], [])`. THEN, for each entry in `linked`, set `entry.hc.setBroadcastFcn(@(x) obj.broadcastCrosshairX_(entry.hc, x), @() obj.broadcastCrosshairLeave_(entry.hc))`. Wrap per-handle in try/catch. Only meaningful when >=1 linked widget, but clearing must always run. Guard: if `obj.HoverCrosshair_`-style headless (no crosshairs) it is a no-op. + + 3. `broadcastCrosshairX_(obj, sourceHc, xQuery)` — re-collect `linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets())` (cheap; active page only; respects the 0.025s throttle upstream because this only fires from a source onMove which is itself throttled). For each entry where `entry.hc ~= sourceHc` (handle inequality), call `entry.hc.onMoveExternal(xQuery)` in try/catch. AVOID per-tick allocations beyond the small cell — acceptable per perf constraint (broadcast path runs at most ~40Hz, N widgets small). + + 4. `broadcastCrosshairLeave_(obj, sourceHc)` — re-collect linked; for each peer `~= sourceHc` call `entry.hc.onLeaveExternal()` in try/catch. + + 5. `onCrosshairLinkToggle(obj, widget)` — PUBLIC. Called by the DashboardLayout button callback AFTER `widget.setCrosshairLink(tf)` flips the flag. Just calls `obj.rewireCrosshairLinks_()` (re-derives the whole active-page set from current flags — idempotent, simplest correct behaviour). Wrap in try/catch + namespaced warning 'DashboardEngine:crosshairLinkToggleFailed'. + + 6. Lifecycle hooks (the 260512-eu2 re-establishment lesson): + - In `rerenderWidgets`, add `obj.rewireCrosshairLinks_();` at the END (after the CreateEventCallback re-wire at line ~1804), wrapped in try/catch warning 'DashboardEngine:crosshairRewireFailed'. The fresh HoverCrosshair_ handles created by the realizeWidget loop get their broadcast hooks (re-)primed here. + - In `switchPage`, after the page is made active + widgets realized/refreshed, call `obj.rewireCrosshairLinks_();` (try/catch). This drops the previously-active page's wiring (those widgets are no longer in activePageWidgets) and primes the now-active page. + - In `detachWidget`, best-effort clear the detaching widget's broadcast hook before/while detaching: `if isa(widget,'FastSenseWidget') && ~isempty(widget.FastSenseObj) && ~isempty(widget.FastSenseObj.HoverCrosshair_); try widget.FastSenseObj.HoverCrosshair_.setBroadcastFcn([],[]); catch; end; end`. Then call `obj.rewireCrosshairLinks_()` after detach completes so the remaining active-page set is consistent. (The detached widget keeps CrosshairLinked=true in its serialized state but no longer participates because it left activePageWidgets.) + + Tests — APPEND to tests/test_fastsense_crosshair_link.m: + - PURE: build a DashboardEngine with 3 FastSenseWidgets on one page (use the existing makeTag_/widget helpers from test_fastsense_widget_ylimit_modes.m as a model; render headless via canRenderFigures_ or, for the PURE enumeration case, hand-build a fake widget list of structs that duck-type .CrosshairLinked/.FastSenseObj so collectLinkedCrosshairs_ can be exercised WITHOUT a figure). Assert `collectLinkedCrosshairs_` returns only the widgets with CrosshairLinked==true and a valid rendered crosshair; flipping a flag changes the count; a GroupWidget-nested linked FastSenseWidget IS included (flatten works). + - RENDER-GUARDED end-to-end: render a 2-widget dashboard, setCrosshairLink(true) on both, call rewireCrosshairLinks_, then simulate hover by calling widgetA.FastSenseObj.HoverCrosshair_.onMove(xMid) and assert widgetB's crosshair line became Visible='on' at xMid (mirror works); then call onLeave on A and assert B hides. This is the integration proof. + Keep the render cases behind canRenderFigures_() + Octave skip; keep the collectLinkedCrosshairs_ enumeration case PURE (runs everywhere). + + + MISSING — orchestrator runs live MATLAB. Static check: `grep -n "collectLinkedCrosshairs_\|broadcastCrosshairX_\|broadcastCrosshairLeave_\|onCrosshairLinkToggle\|rewireCrosshairLinks_" libs/Dashboard/DashboardEngine.m` shows all five; `grep -n "rewireCrosshairLinks_" libs/Dashboard/DashboardEngine.m` appears in rerenderWidgets, switchPage, detachWidget AND the method def (>=4 hits). Orchestrator runs: `mcp__matlab__run_matlab_test_file tests/test_fastsense_crosshair_link.m` (expect all pass) + regression `tests/test_time_range_selector_reinstall_after_rerender.m` (the eu2/egv guard — must stay green) + `tests/test_dashboard_time_sync_all_pages.m`. + + DashboardEngine derives the active-page link set on demand from CrosshairLinked flags (flattening GroupWidget children), broadcasts a hovered widget's data-x to all OTHER linked widgets' crosshairs via onMoveExternal, broadcasts leave via onLeaveExternal, and re-primes the broadcast hooks after rerenderWidgets, switchPage, and detachWidget. collectLinkedCrosshairs_ is pure and unit-tested. No new figure-WBM closures; the TRS reinstall regression test stays green. + + + + Task 3: Crosshair-link toggle button on the WidgetButtonBar (duck-typed) + protect from re-render sweep + libs/Dashboard/DashboardLayout.m, libs/Dashboard/DashboardWidget.m + + Add the toggle button (MRI-04), mirroring addPlantLogToggle/addYLimitButtons_ exactly. + + 1. In `DashboardLayout.realizeWidget` (inside the `if needsBar` body): after the `addYLimitButtons_` block (line ~401-403) and after the `addPlantLogToggle` block (line ~405-413), and BEFORE the final `reflowChrome_(widget.hCellPanel, 28, 2)` (line ~420), add: + `if ismethod(widget, 'setCrosshairLink'); try obj.addCrosshairLinkToggle(widget); catch ME; warning('DashboardLayout:crosshairToggleFailed', 'addCrosshairLinkToggle failed during realizeWidget: %s', ME.message); end; end` + (No needsBar change needed — FastSenseWidget already triggers needsBar via setYLimitMode.) + + 2. New method `addCrosshairLinkToggle(obj, widget)` (model on addPlantLogToggle): + - Resolve theme: `if isempty(widget.ParentTheme) || ~isstruct(widget.ParentTheme); theme = DashboardTheme('light'); else; theme = widget.ParentTheme; end`. + - `bar = obj.getOrCreateButtonBar_(widget);` + - Idempotent: delete any prior `findobj(bar,'Tag','CrosshairLinkButton','-depth',1)`. + - Position: this is the LEFTMOST chrome button. Compute its x as LEFT of the V/A cluster. The initial x can be a best-effort (the final position is settled by reflowChrome_): mirror addYLimitButtons_'s left-anchored math and subtract one more `(bw+gap)`. Simplest robust approach: place it provisionally (e.g. `xLink = 2`) and rely on the realizeWidget-tail reflowChrome_ to anchor it correctly — but PREFER computing it consistently with reflowChrome_ (xVisible - gap - bw) using the same hasCreate/hasPlantLog detection so the very first paint is right. 24x24, FontSize 9, FontWeight bold. + - Glyph 'X', Tag 'CrosshairLinkButton', TooltipString: linked -> 'Unlink crosshair (stop mirroring)'; unlinked -> 'Link crosshair across page'. ForegroundColor theme.ToolbarFontColor. BackgroundColor: when `widget.CrosshairLinked` use `DashboardLayout.chooseYLimitActiveBg_(theme)` (highlighted), else `theme.ToolbarBackground`. + - Callback: `@(s,~) obj.onCrosshairLinkTogglePressed_(s, widget)`. + - After creating, call `DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2)` in try/catch (matches addPlantLogToggle's tail so callback-driven rebuilds re-anchor). + + 3. New method `onCrosshairLinkTogglePressed_(obj, src, widget)` (model on onPlantLogTogglePressed_): + - try: `widget.setCrosshairLink(~widget.CrosshairLinked);` then notify the engine: `if ~isempty(obj.EngineRef) && isa(obj.EngineRef,'DashboardEngine'); obj.EngineRef.onCrosshairLinkToggle(widget); end`. Then rebuild the button look: `obj.addCrosshairLinkToggle(widget);`. + - catch ME: `warning('DashboardLayout:crosshairToggleFailed', 'Crosshair-link toggle callback failed: %s', ME.message);` + best-effort non-blocking uialert if a uifigure ancestor exists (copy the onPlantLogTogglePressed_ uialert block). + + 4. In `reflowChrome_` (STATIC, line 1068): after the V/A cluster re-anchor block (lines ~1130-1146), add the CrosshairLink re-anchor as the LEFTMOST button: + `link = findobj(bar(1),'Tag','CrosshairLinkButton','-depth',1);` + `if ~isempty(link) && ishandle(link(1)); xLink = xVisible - gap - bw; set(link(1),'Position',[xLink,2,bw,bw]); end` + (xVisible is already computed for the V/A cluster just above; reuse it. If V/A absent for some future duck-typed widget, fall back to anchoring left of the right-cluster — but FastSenseWidget always has V/A, so the simple `xVisible - gap - bw` is correct for the only consumer today. Add a brief comment noting the leftmost-button assumption.) + + 5. In `DashboardWidget.clearPanelControls` (line 145-147): append `'CrosshairLinkButton'` to `protectedTags` so the button survives the depth-1 uicontrol sweep on re-render. + + + MISSING — orchestrator runs live MATLAB. Static check: `grep -n "CrosshairLinkButton\|addCrosshairLinkToggle\|onCrosshairLinkTogglePressed_" libs/Dashboard/DashboardLayout.m` shows the button Tag + both methods + reflow re-anchor (>=5 hits); `grep -n "CrosshairLinkButton" libs/Dashboard/DashboardWidget.m` shows it in protectedTags; `grep -n "setCrosshairLink\|onCrosshairLinkToggle" libs/Dashboard/DashboardLayout.m` shows the realizeWidget duck-type + the EngineRef callback. Orchestrator will smoke-test on the live demo: button renders with Tag CrosshairLinkButton on the WidgetButtonBar left of V/A, clicking it toggles widget.CrosshairLinked + highlight, and after a top-toolbar Reset + figure resize the button stays anchored and linking still mirrors. + + Every FastSenseWidget tile shows an 'X' crosshair-link toggle on its WidgetButtonBar (leftmost chrome button, left of V/A), duck-typed via ismethod(widget,'setCrosshairLink'). Clicking flips CrosshairLinked, highlights when linked, and calls EngineRef.onCrosshairLinkToggle to (re)wire the active-page link set. reflowChrome_ re-anchors it on resize; clearPanelControls protects it from re-render sweeps. The callback never throws into the refresh loop (try/catch + warning + non-blocking uialert). + + + + + +Overall checks (orchestrator runs these in live MATLAB — executor cannot): + +1. `mcp__matlab__run_matlab_test_file tests/test_fastsense_crosshair_link.m` — all cases pass (PURE on MATLAB+Octave; render cases on MATLAB desktop). +2. Regression (MUST stay green): + - `tests/test_fastsense_widget_ylimit_modes.m` (V/A/L untouched) + - `tests/test_hover_crosshair.m` (standalone hover unchanged) + - `tests/test_time_range_selector_reinstall_after_rerender.m` (the 260512-egv/eu2 chained-WBM regression guard — proves no dangling-closure regression) + - `tests/test_dashboard_time_sync_all_pages.m` (multi-page sweep) +3. MISS_HIT clean on all 5 edited files + the new test: `mh_style` + `mh_lint` report no new findings (`mcp__matlab__check_matlab_code` on each). +4. Live demo smoke (orchestrator, on `demo/industrial_plant/run_demo.m`): + - Two FastSense widgets on the active page, click X on both -> hovering one mirrors the crosshair + per-series datatip onto the other at the same x; cursor leave hides both. + - Click X off on one -> it stops mirroring/receiving; the other keeps its own hover. + - Top-toolbar Reset, then re-hover -> mirroring still works (re-wire after rerender). + - Drag-resize the figure -> X stays anchored left of V/A; mirroring still works. + - Switch page (multi-page demo) -> link set scoped to the now-active page; previous page's widgets dropped. + - Open an existing v3.0 serialized dashboard -> all widgets unlinked, behaviour + JSON identical. + + + +- FastSenseWidget.CrosshairLinked (public, default false) + setCrosshairLink(tf) + toStruct (omit when false) + fromStruct (restore when present). Legacy dashboards load unchanged with identical JSON. +- HoverCrosshair.onMove broadcasts data-x via BroadcastFcn_ exactly once; onMoveExternal mirrors without re-broadcasting; onLeave honors a suppress-leave tic-window so a mirrored peer is not hidden by its own same-dispatch onLeave; onLeaveExternal hides cleanly. ZERO new figure WindowButtonMotionFcn closures. +- DashboardEngine derives the active-page link set from CrosshairLinked over flattened active-page widgets (GroupWidget children included), broadcasts hover-x to all OTHER linked widgets, and re-primes broadcast hooks after rerenderWidgets / switchPage / detachWidget. +- An 'X' toggle on the WidgetButtonBar (leftmost chrome button) flips the flag, highlights when linked, re-anchors on resize, survives Reset, and is protected from clearPanelControls sweeps. +- tests/test_fastsense_crosshair_link.m: >=8 cases incl. the suppress-leave unit proof + a 2-widget mirror integration proof + a pure collectLinkedCrosshairs_ enumeration test (with a GroupWidget-nested case). Existing hover + V/A/L + TRS-reinstall + multi-page tests stay green. +- All errors namespaced (FastSenseWidget:* / DashboardLayout:* / DashboardEngine:*); MISS_HIT clean; MATLAB+Octave-safe (render paths gated, pure paths run everywhere). + + + +After completion, create `.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-SUMMARY.md`. +Record: the locked design decisions (D-01..D-07), files changed with LOC deltas, the suppress-leave mechanism explanation, test results (filled in by the orchestrator's live MATLAB run), and any deferred items (e.g. detached-mirror crosshair-link parity is OUT OF SCOPE — DetachedMirror uses a figure-level FastSenseToolbar, not a WidgetButtonBar, matching the 260513-sfp detached-V/A/L precedent). + diff --git a/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-SUMMARY.md b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-SUMMARY.md new file mode 100644 index 00000000..a89d88ee --- /dev/null +++ b/.planning/quick/260602-mri-add-crosshair-link-toggle-to-fastsense-w/260602-mri-SUMMARY.md @@ -0,0 +1,139 @@ +--- +phase: 260602-mri +plan: 01 +subsystem: Dashboard / FastSense +tags: [crosshair, link, widget-button-bar, hover, broadcast] +dependency_graph: + requires: [] + provides: + - CrosshairLinked property on FastSenseWidget (MRI-01) + - HoverCrosshair broadcast hook + suppress-leave guard (MRI-02) + - DashboardEngine active-page link coordination (MRI-03) + - Crosshair-link 'X' toggle on WidgetButtonBar (MRI-04) + - Backward-compat: default OFF, legacy JSON unchanged (MRI-05) + affects: + - libs/FastSense/HoverCrosshair.m + - libs/Dashboard/FastSenseWidget.m + - libs/Dashboard/DashboardEngine.m + - libs/Dashboard/DashboardLayout.m + - libs/Dashboard/DashboardWidget.m +tech_stack: + added: [] + patterns: + - Suppress-leave via deterministic IsMirrored_ flag (one-shot, no wall-clock window); onLeaveExternal hides directly to avoid leave recursion + - InBroadcast_ re-entrancy flag prevents peer re-broadcasting + - collectLinkedCrosshairs_ pure enumeration helper (no side effects) + - Duck-type via ismethod(widget,'setCrosshairLink') for WidgetButtonBar injection + - Re-establish-after-rerender pattern (260512-eu2) for BroadcastFcn_ hooks +key_files: + created: + - tests/test_fastsense_crosshair_link.m + modified: + - libs/FastSense/HoverCrosshair.m (+78 lines) + - libs/Dashboard/FastSenseWidget.m (+40 lines) + - libs/Dashboard/DashboardEngine.m (+152 lines) + - libs/Dashboard/DashboardLayout.m (+101 lines, -2 lines) + - libs/Dashboard/DashboardWidget.m (+3 lines, -1 line) +decisions: + - D-01: Link model SHARED SET — any widget with toggle ON both broadcasts AND receives + - D-02: X mapping RAW DATA-X — data-x broadcast; each peer computes its own Y via computeYAtX_ + - D-03: Coordination owner DashboardEngine + per-widget flag (no new class) + - D-04: Suppress-hide crux — broadcast hook + deterministic IsMirrored_ suppress flag; ZERO new figure WBM closures; onLeaveExternal hides directly (no leave recursion) + - D-05: Lifecycle — per-active-page scope; unlink-on-detach; re-prime after rerender/switchPage + - D-06: Backward compat — default OFF, toStruct omits field when false, legacy JSON byte-identical + - D-07: Button glyph ASCII 'X' (Octave-safe; matches existing V/A/L/i/^ ASCII glyphs) +metrics: + duration: "~75 min" + completed: "2026-06-02" + tasks: 3 + files: 6 + loc_added: 748 + loc_removed: 3 +--- + +# Phase 260602-mri Plan 01: Add Crosshair-Link Toggle to FastSenseWidget Summary + +**One-liner:** Per-page crosshair-link broadcast with a deterministic IsMirrored_ suppress flag: hovering any linked FastSenseWidget mirrors its data-x to all OTHER linked widgets' crosshairs via BroadcastFcn_ without adding new figure WBM closures. + +## Tasks Completed + +| Task | Name | Commit | +|------|------|--------| +| 1 | CrosshairLinked property + HoverCrosshair broadcast hook | a495cbc9 | +| 2 | DashboardEngine active-page link coordination | 635632e2 | +| 3 | Crosshair-link toggle button on WidgetButtonBar | 8950abd5 | + +## Files Changed + +| File | LOC Delta | Summary | +|------|-----------|---------| +| `tests/test_fastsense_crosshair_link.m` | +377 | New test file: 11 cases (6 PURE + 1 pure-engine enumeration + 2 render-guarded suppress-leave/broadcast-reentry + 2 render-guarded 2-widget integration) | +| `libs/Dashboard/DashboardEngine.m` | +152 | collectLinkedCrosshairs_, rewireCrosshairLinks_, broadcastCrosshairX_, broadcastCrosshairLeave_, onCrosshairLinkToggle; lifecycle hooks in rerenderWidgets/switchPage/detachWidget | +| `libs/Dashboard/DashboardLayout.m` | +101, -2 | addCrosshairLinkToggle, onCrosshairLinkTogglePressed_, reflowChrome_ CrosshairLinkButton re-anchor, duck-type injection in realizeWidget | +| `libs/FastSense/HoverCrosshair.m` | +78 | setBroadcastFcn, onMoveExternal, onLeaveExternal; suppress-leave guard in onLeave; BroadcastFcn_ tail in onMove; delete() nulls callbacks first | +| `libs/Dashboard/FastSenseWidget.m` | +40 | CrosshairLinked=false public property; setCrosshairLink(tf) setter; toStruct omit-when-false; fromStruct pre-render restore | +| `libs/Dashboard/DashboardWidget.m` | +3, -1 | CrosshairLinkButton added to clearPanelControls protectedTags | + +## Suppress-Leave Mechanism Explanation + +The core design challenge (D-04): the single dashboard-figure `WindowButtonMotionFcn` dispatches through a chain of every widget's `HoverCrosshair.onFigureMove_`. When the cursor is over widget A, A's handler resolves "inside" and fires `onMove(xQuery)`; B's handler resolves "outside" and fires `onLeave()` (hide). Without intervention, mirroring A's x onto B would be immediately undone by B's own `onLeave()` in the same dispatch cycle. + +**Solution:** Zero new figure-WBM closures. The broadcast rides on EXISTING per-crosshair `onMove`/`onLeave` calls, gated by a **deterministic `IsMirrored_` boolean** (NOT a wall-clock window — see "Orchestrator Verification & Fixes" for why the original tic-window was replaced): + +1. `BroadcastFcn_` callback on HoverCrosshair, fired at the END of `onMove(xQuery)` only when not in `InBroadcast_` mode. The engine sets this to `@(x) engine.broadcastCrosshairX_(thisHc, x)`. +2. `broadcastCrosshairX_` loops active-page linked crosshairs and calls `peer.onMoveExternal(xQuery)` for each peer != source. +3. `onMoveExternal(x)` sets `IsMirrored_ = true` then calls `onMove(x)` with `InBroadcast_=true`. `onLeave()` early-returns whenever `IsMirrored_` is true. So when peer B's own `onFigureMove_` fires `onLeave()` in the same motion dispatch (cursor not over B), the guard swallows it and B's mirrored crosshair stays visible — regardless of how long the dispatch takes. +4. A REAL hover (`onMove` with `InBroadcast_=false`) clears `IsMirrored_` — the widget under the cursor becomes the link **source**. +5. When the cursor leaves ALL linked axes: the source's `onFigureMove_` fires its own `onLeave()`. The source has `IsMirrored_=false`, so it hides and, via `BroadcastLeaveFcn_`, calls `broadcastCrosshairLeave_` → `peer.onLeaveExternal()` on all peers. `onLeaveExternal` sets `IsMirrored_=false` and hides **directly** (it does NOT call `onLeave`, so peers never re-broadcast — no leave ping-pong). + +**Key invariant:** there is always exactly one source (the last widget whose real `onMove` ran, `IsMirrored_=false`); on cursor-exit the source's `onLeave` always fires and broadcasts leave, clearing every peer. A mirrored peer (`IsMirrored_=true`) never hides on its own self-leave. This is timing-independent, so it cannot race the synchronous motion dispatch. + +## Design Decisions + +**D-01 — Link model SHARED SET.** Any widget with toggle ON both broadcasts on hover AND receives mirrors. No master/source role distinction. + +**D-02 — X mapping RAW DATA-X.** Sensor dashboards share a time axis. `HoverCrosshair.onMove(xQuery)` already computes each line's Y via `computeYAtX_` (binary_search), so each mirrored widget shows ITS OWN series values at the shared x for free. No Y is transmitted. + +**D-03 — Coordination owner DashboardEngine + per-widget flag (NO new class).** The link set is derived on demand from `FastSenseWidget.CrosshairLinked` over the flattened active-page widgets (`collectLinkedCrosshairs_`). + +**D-04 — Suppress-hide crux.** See "Suppress-Leave Mechanism Explanation" above. Zero new WBM closures — matches the 260512-egv/260512-eu2 design constraint. + +**D-05 — Lifecycle per-active-page.** `collectLinkedCrosshairs_` only walks `activePageWidgets()` so page-switch automatically scopes the link set. `rewireCrosshairLinks_` is called after `rerenderWidgets`, `switchPage`, and `detachWidget` to re-prime fresh HoverCrosshair_ handles (260512-eu2 lesson). + +**D-06 — Backward compat default OFF.** `CrosshairLinked = false` by default. `toStruct` omits `crosshairLinked` when false. `fromStruct` assigns the raw property (no setter call that would touch graphics) so pre-render load is safe. Legacy serialized dashboards load unchanged with identical JSON. + +**D-07 — Button glyph ASCII 'X'.** Octave-safe (no Unicode). Matches existing ASCII Info ('i') / Detach ('^') / V / A / L glyphs. + +## Deferred Items + +**DetachedMirror crosshair-link parity — OUT OF SCOPE.** A detached widget opens in its own standalone figure with a `FastSenseToolbar` (figure-level crosshair toggle), not a `WidgetButtonBar`. This matches the 260513-sfp precedent where V/A/L buttons are also absent on detached widgets. Implementing crosshair-link on DetachedMirror would require wiring the detached figure's toolbar to the engine's active-page link set, which is a separate effort. + +## Orchestrator Verification & Fixes + +Live MATLAB verification (R2025a) found three issues in the executor's commits, all fixed in commit `260602-mri-04` (HoverCrosshair.m + test): + +1. **Flaky time-window suppress (replaced).** The original `SuppressLeaveUntil_ = tic` / `toc < SuppressWindow_ (0.075s)` guard depends on wall-clock elapsed time *inside a synchronous motion dispatch*. The crux test failed in the full suite run (the window expired before the self-leave) yet passed in isolation (6.7ms) — i.e. genuinely flaky. Replaced with a deterministic `IsMirrored_` boolean (set by `onMoveExternal`, cleared when the widget becomes the hover source or via `onLeaveExternal`). Timing-independent; correct under every chained-dispatch ordering (verified by trace + the now-passing render-guarded tests). +2. **Latent infinite-recursion in the leave path (fixed).** The original `onLeaveExternal` cleared the suppress then called `onLeave()`, which re-broadcast leave → peer `onLeaveExternal` → `onLeave` → re-broadcast … an unbounded ping-pong that would hang MATLAB with ≥2 linked widgets. (The committed tests never hit it: the crux test has no peers, and the integration test died earlier on bug #3.) Fixed by having `onLeaveExternal` hide **directly** via a new private `hideGraphics_` helper — never re-entering the broadcasting `onLeave`. +3. **Two test-construction bugs (fixed).** `test_collect_linked_crosshairs_pure_enumeration` and `test_two_widget_mirror_integration` set `eng.Widgets = {...}`, but `DashboardEngine.Widgets` is `SetAccess=private`. Fixed: the enumeration test passes the cell list straight to `collectLinkedCrosshairs_` (it takes the list as a parameter); the integration test uses `eng.addWidget(w)` (which accepts pre-built widget handles). + +## Test Results — ALL GREEN (MATLAB R2025a, live MCP) + +| Test | Result | +|------|--------| +| `tests/test_fastsense_crosshair_link.m` (NEW) | **11/11 pass** (incl. deterministic suppress-leave crux + 2-widget mirror integration) | +| `tests/test_hover_crosshair.m` (regression — standalone hover) | **11/11 pass** | +| `tests/test_fastsense_widget_ylimit_modes.m` (regression — V/A/L) | **11/11 pass** | +| `tests/test_time_range_selector_reinstall_after_rerender.m` (regression — chained-WBM/egv-eu2 guard) | **pass** | +| `tests/test_dashboard_time_sync_all_pages.m` (regression — multi-page) | **5/5 pass** | +| MISS_HIT `mh_style` + `mh_lint` (all 6 files) | **clean** (everything seems fine) | +| MATLAB Code Analyzer | no new findings (5 pre-existing `%#ok` stale-pragma infos, present on `main` baseline) | + +**Live UI smoke (real rendered 2-widget dashboard, figure on screen):** `CrosshairLinkButton` ('X', 24×24) renders on each FastSenseWidget's grey WidgetButtonBar; clicking it flips `CrosshairLinked` and wires the engine link set; hovering one linked widget mirrors the crosshair + per-series datatip onto the other at the same x; cursor-leave hides both; unlinking a widget stops it broadcasting/receiving; a still-linked solo widget keeps its own hover crosshair. Octave: pure cases run; render-guarded cases skip (HoverCrosshair is MATLAB-only) — consistent with `test_hover_crosshair.m`. + +## Self-Check: PASSED + +All 6 files exist on disk. All 4 commits exist in git log: +- a495cbc9 Task 1: CrosshairLinked property + HoverCrosshair broadcast hook +- 635632e2 Task 2: DashboardEngine active-page link coordination +- 8950abd5 Task 3: Crosshair-link toggle button on WidgetButtonBar +- (260602-mri-04) Orchestrator fixes: deterministic IsMirrored_ suppress + leave-recursion fix + test-construction fixes diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index bc92e892..0fa46bd7 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -371,6 +371,14 @@ function switchPage(obj, pageIdx) end % Phase 1039 — recompute the current-view box for the newly active page. try obj.updateCurrentViewIndicator_(); catch, end + % 260602-mri — re-prime crosshair broadcast hooks for the now-active page. + % Previous-page crosshairs are dropped (they are no longer in activePageWidgets). + try + obj.rewireCrosshairLinks_(); + catch ME + warning('DashboardEngine:crosshairRewireFailed', ... + 'rewireCrosshairLinks_ failed after switchPage: %s', ME.message); + end end function w = addWidget(obj, type, varargin) @@ -1545,6 +1553,20 @@ function detachWidget(obj, widget) % is a handle class, so mutations to it are visible through all % references. + % 260602-mri — best-effort clear this widget's broadcast hook before + % detaching so it stops participating in the active-page link set. + % The widget keeps CrosshairLinked=true in serialized state but will + % no longer be in activePageWidgets() after detach, so + % collectLinkedCrosshairs_ naturally excludes it. The explicit clear + % here avoids a stale closure firing between the detach and the next + % rewireCrosshairLinks_ call. + try + if isa(widget, 'FastSenseWidget') && ~isempty(widget.FastSenseObj) && ... + ~isempty(widget.FastSenseObj.HoverCrosshair_) + widget.FastSenseObj.HoverCrosshair_.setBroadcastFcn([], []); + end + catch + end themeStruct = obj.getCachedTheme(); % containers.Map is a handle object — mutations after closure creation % are visible through the captured reference (unlike cells/structs). @@ -1569,6 +1591,12 @@ function detachWidget(obj, widget) warning('DashboardEngine:plantLogOverlayFailed', ... 'detachWidget plant-log re-attach failed: %s', err.message); end + % 260602-mri — re-derive active-page link set after detach so + % remaining widgets' broadcast hooks reflect the updated set. + try + obj.rewireCrosshairLinks_(); + catch + end end function removeDetached(obj) @@ -1802,6 +1830,14 @@ function rerenderWidgets(obj) obj.Layout.DetachCallback = @(w) obj.detachWidget(w); % 260513-snt — re-wire Create-Event callback for the same reason. obj.Layout.CreateEventCallback = @(w) obj.openCreateEventDialog_(w); + % 260602-mri — re-prime crosshair broadcast hooks on freshly-built + % HoverCrosshair_ handles (each realizeWidget creates a new HC). + try + obj.rewireCrosshairLinks_(); + catch ME + warning('DashboardEngine:crosshairRewireFailed', ... + 'rewireCrosshairLinks_ failed after rerenderWidgets: %s', ME.message); + end end function updateGlobalTimeRange(obj) @@ -2856,6 +2892,122 @@ function setTimeRangeSelectorForTest_(obj, sel) end end + % 260602-mri — crosshair-link coordination + function linked = collectLinkedCrosshairs_(obj, widgets) + %COLLECTLINKEDCROSSHAIRS_ Enumerate linked+rendered crosshairs on active page (260602-mri). + % linked = collectLinkedCrosshairs_(obj, widgets) flattens widgets via + % flattenWidgetsForPreview_ and returns a cell array of structs: + % {struct('widget', w, 'hc', hc), ...} + % for every flattened FastSenseWidget with CrosshairLinked=true AND a + % valid rendered HoverCrosshair_. Widgets failing any guard are silently + % skipped. PURE (no side effects) so it is unit-testable with a + % hand-built widget list. + % Made public (Access=public) so tests and DashboardLayout can call it. + linked = {}; + if nargin < 2 || isempty(widgets); return; end + isOctave = exist('OCTAVE_VERSION', 'builtin') ~= 0; + try + flat = obj.flattenWidgetsForPreview_(widgets); + catch + flat = {}; + end + for i = 1:numel(flat) + w = flat{i}; + try + if ~isa(w, 'FastSenseWidget'); continue; end + if ~w.CrosshairLinked; continue; end + if isempty(w.FastSenseObj); continue; end + if ~isa(w.FastSenseObj, 'FastSense'); continue; end + if ~w.FastSenseObj.IsRendered; continue; end + hc = w.FastSenseObj.HoverCrosshair_; + if isempty(hc); continue; end + if ~isOctave && ~isvalid(hc); continue; end + linked{end + 1} = struct('widget', w, 'hc', hc); %#ok + catch + % skip any widget that errors during guard checks + end + end + end + + function rewireCrosshairLinks_(obj) + %REWIRECEOSSHAIRLINKS_ Re-prime BroadcastFcn_ on active-page linked crosshairs (260602-mri). + % 1. Clear BroadcastFcn_/BroadcastLeaveFcn_ on ALL active-page FastSense + % crosshairs (handles toggled-OFF widgets + previous-page crosshairs). + % 2. For each currently-linked+rendered crosshair, install the engine + % broadcast callbacks. Must be called after rerenderWidgets (fresh + % HoverCrosshair_ handles), after switchPage, and after detachWidget. + % Wrapped in try/catch at call sites; inner per-handle errors are silently + % skipped so a single bad crosshair never breaks the whole sweep. + isOctave = exist('OCTAVE_VERSION', 'builtin') ~= 0; + try + flat = obj.flattenWidgetsForPreview_(obj.activePageWidgets()); + catch + flat = {}; + end + % Pass 1: clear all active-page crosshairs. + for i = 1:numel(flat) + w = flat{i}; + try + if ~isa(w, 'FastSenseWidget'); continue; end + if isempty(w.FastSenseObj); continue; end + hc = w.FastSenseObj.HoverCrosshair_; + if isempty(hc); continue; end + if ~isOctave && ~isvalid(hc); continue; end + hc.setBroadcastFcn([], []); + catch + end + end + % Pass 2: wire the linked crosshairs. + linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets()); + if numel(linked) < 1; return; end + for i = 1:numel(linked) + entry = linked{i}; + hc = entry.hc; + try + hc.setBroadcastFcn( ... + @(x) obj.broadcastCrosshairX_(hc, x), ... + @() obj.broadcastCrosshairLeave_(hc)); + catch + end + end + end + + function broadcastCrosshairX_(obj, sourceHc, xQuery) + %BROADCASTCROSSHAIRX_ Mirror xQuery onto all OTHER linked crosshairs on active page (260602-mri). + % Fired at end of source crosshair's onMove (via BroadcastFcn_). + % Re-collects the linked set each call (cheap; active page only; + % upstream throttle limits call rate to ~40 Hz; N widgets small). + linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets()); + for i = 1:numel(linked) + entry = linked{i}; + if entry.hc == sourceHc; continue; end + try entry.hc.onMoveExternal(xQuery); catch; end + end + end + + function broadcastCrosshairLeave_(obj, sourceHc) + %BROADCASTCROSSHAIRLEAVE_ Tell all OTHER linked crosshairs to hide (260602-mri). + % Fired at end of source crosshair's onLeave (via BroadcastLeaveFcn_). + linked = obj.collectLinkedCrosshairs_(obj.activePageWidgets()); + for i = 1:numel(linked) + entry = linked{i}; + if entry.hc == sourceHc; continue; end + try entry.hc.onLeaveExternal(); catch; end + end + end + + function onCrosshairLinkToggle(obj, widget) + %ONCROSSHAIRLINKTOGGLE Called by DashboardLayout after widget.setCrosshairLink(tf) (260602-mri). + % Re-derives the whole active-page link set from current flags — idempotent. + % Wrapped in try/catch so a single toggle failure never crashes the bar. + try + obj.rewireCrosshairLinks_(); + catch ME + warning('DashboardEngine:crosshairLinkToggleFailed', ... + 'rewireCrosshairLinks_ failed during onCrosshairLinkToggle: %s', ME.message); + end + end + function notifyEventsChanged(obj) %NOTIFYEVENTSCHANGED Refresh all event-aware widgets after store mutation (260513-snt). % Called after CreateEventDialog persists a new event. Walks the diff --git a/libs/Dashboard/DashboardLayout.m b/libs/Dashboard/DashboardLayout.m index a457cf93..0dbb65d6 100644 --- a/libs/Dashboard/DashboardLayout.m +++ b/libs/Dashboard/DashboardLayout.m @@ -411,12 +411,24 @@ function realizeWidget(obj, widget) 'addPlantLogToggle failed during realizeWidget: %s', ME.message); end end + % 260602-mri — crosshair-link toggle. Duck-typed via + % ismethod(widget,'setCrosshairLink') so only FastSenseWidget + % opts in today (setCrosshairLink is the duck-type hook). + % Lives inside needsBar because it requires the WidgetButtonBar. + if ismethod(widget, 'setCrosshairLink') + try + obj.addCrosshairLinkToggle(widget); + catch ME + warning('DashboardLayout:crosshairToggleFailed', ... + 'addCrosshairLinkToggle failed during realizeWidget: %s', ME.message); + end + end % v4.0 260513-snt — settle final right-anchored button positions. % addInfoIcon runs BEFORE addCreateEventButton, so Info's % initial X collides with Create's slot. reflowChrome_ knows % the full layout (V/A left cluster + Info/Create/Detach right - % cluster + Plant Log toggle) and re-anchors everything in one - % pass. + % cluster + Plant Log toggle + CrosshairLink leftmost) and + % re-anchors everything in one pass. DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2); else % No chrome — render directly into the cell panel as before. @@ -757,6 +769,81 @@ function onPlantLogTogglePressed_(obj, src, widget, engine) end end + function addCrosshairLinkToggle(obj, widget) + %ADDCROSSHAIRLINKTOGGLE Inject crosshair-link 'X' button into WidgetButtonBar (260602-mri). + % Duck-typed: only called for widgets where ismethod(widget,'setCrosshairLink'). + % Idempotent: removes any prior CrosshairLinkButton before creating the new one. + % Glyph: 'X' (ASCII, Octave-safe — matches existing V/A/L/i/^ glyphs). + % Position: leftmost chrome button, placed to the LEFT of the V/A cluster; + % final position settled by reflowChrome_ (reflowChrome_ is called from + % both realizeWidget and from this method for callback-driven rebuilds). + % Active (linked) state highlighted via chooseYLimitActiveBg_ (same as V/A). + if isempty(widget.ParentTheme) || ~isstruct(widget.ParentTheme) + theme = DashboardTheme('light'); + else + theme = widget.ParentTheme; + end + bar = obj.getOrCreateButtonBar_(widget); + % Idempotent: delete any prior CrosshairLinkButton. + prior = findobj(bar, 'Tag', 'CrosshairLinkButton', '-depth', 1); + if ~isempty(prior) + try delete(prior); catch, end + end + % Provisional position (reflowChrome_ will re-anchor on resize). + % Use xLink = 2 as placeholder so the button is always inside + % the bar. reflowChrome_ computes the definitive left-of-V/A coord. + xLink = 2; + if widget.CrosshairLinked + tipStr = 'Unlink crosshair (stop mirroring)'; + bgColor = DashboardLayout.chooseYLimitActiveBg_(theme); + else + tipStr = 'Link crosshair across page'; + bgColor = theme.ToolbarBackground; + end + uicontrol('Parent', bar, ... + 'Style', 'pushbutton', ... + 'String', 'X', ... + 'Units', 'pixels', ... + 'Position', [xLink 2 24 24], ... + 'FontSize', 9, ... + 'FontWeight', 'bold', ... + 'ForegroundColor', theme.ToolbarFontColor, ... + 'BackgroundColor', bgColor, ... + 'Tag', 'CrosshairLinkButton', ... + 'TooltipString', tipStr, ... + 'Callback', @(s, ~) obj.onCrosshairLinkTogglePressed_(s, widget)); + try + DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2); + catch + % Best-effort reflow — must not break the toggle. + end + end + + function onCrosshairLinkTogglePressed_(obj, src, widget) + %ONCROSSHAIRLINKTOGLEPRESSED_ CrosshairLink toggle callback (260602-mri). + % Flips widget.CrosshairLinked, notifies the engine, and rebuilds the + % button visual. All errors are caught and surfaced as namespaced + % warnings so no toggle failure can crash the dashboard refresh loop. + try + widget.setCrosshairLink(~widget.CrosshairLinked); + if ~isempty(obj.EngineRef) && isa(obj.EngineRef, 'DashboardEngine') + obj.EngineRef.onCrosshairLinkToggle(widget); + end + % Rebuild button look (highlight / tooltip). + obj.addCrosshairLinkToggle(widget); + catch ME + warning('DashboardLayout:crosshairToggleFailed', ... + 'Crosshair-link toggle callback failed: %s', ME.message); + try + fig = ancestor(src, 'figure'); + if ~isempty(fig) && ishandle(fig) && isa(fig, 'matlab.ui.Figure') + uialert(fig, ME.message, 'Crosshair link toggle failed', 'Icon', 'error'); + end + catch + end + end + end + end methods (Access = private) @@ -1144,6 +1231,16 @@ function reflowChrome_(hCell, barH, inset) if ~isempty(visibleBtn) && ishandle(visibleBtn(1)) set(visibleBtn(1), 'Position', [xVisible, 2, bw, bw]); end + % 260602-mri — CrosshairLinkButton: LEFTMOST chrome button, + % sits to the LEFT of the V/A cluster. + % Assumption: FastSenseWidget always has V/A, so xVisible is + % always set when CrosshairLinkButton is present. Add a brief + % comment noting the leftmost-button assumption. + linkBtn = findobj(bar(1), 'Tag', 'CrosshairLinkButton', '-depth', 1); + if ~isempty(linkBtn) && ishandle(linkBtn(1)) + xLink = xVisible - gap - bw; + set(linkBtn(1), 'Position', [xLink, 2, bw, bw]); + end end if ~isempty(content) && ishandle(content(1)) contentH = max(1, pp(4) - barH - inset); diff --git a/libs/Dashboard/DashboardWidget.m b/libs/Dashboard/DashboardWidget.m index 999379a5..ef474dcc 100644 --- a/libs/Dashboard/DashboardWidget.m +++ b/libs/Dashboard/DashboardWidget.m @@ -142,9 +142,10 @@ function clearPanelControls(hPanel) % v3.1 Phase 1032 PLOG-VIZ-05 — protect plant-log toggle from re-render sweeps. % v4.0 — '+Event' button (Tag='CreateEventButton') + V/A Y-limit cluster % (Tags 'YLimitVisibleBtn', 'YLimitAllBtn') also preserved. + % 260602-mri — protect crosshair-link toggle button from re-render sweeps. protectedTags = {'InfoIconButton', 'DetachButton', 'WidgetButtonBar', ... 'YLimitVisibleBtn', 'YLimitAllBtn', 'CreateEventButton', ... - 'PlantLogToggleButton'}; + 'PlantLogToggleButton', 'CrosshairLinkButton'}; % Sweep depth-1 uicontrols (legacy-positioned buttons). kids = findobj(hPanel, '-depth', 1, 'Type', 'uicontrol'); for i = 1:numel(kids) diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m index e5c30206..942f8c52 100644 --- a/libs/Dashboard/FastSenseWidget.m +++ b/libs/Dashboard/FastSenseWidget.m @@ -64,6 +64,15 @@ % 4. YLimitMode dispatch: 'locked' -> no-op; otherwise % auto-visible / auto-all branches run. YLimitMode = 'auto-visible' + + % CrosshairLinked — when true this widget joins the active-page + % crosshair-link set (260602-mri). Moving the hover crosshair + % over any linked FastSenseWidget broadcasts the data-x to all + % OTHER linked widgets on the same page, so each mirrors the + % crosshair + per-series datatip at that x for cross-plot + % comparison. Default false -> backward-compatible (existing + % dashboards and serialized JSON are byte-identical). + CrosshairLinked = false end % (Tag property now lives on the DashboardWidget base class — Plan 1009-02.) @@ -591,6 +600,25 @@ function setYLimitMode(obj, mode) end end + function setCrosshairLink(obj, tf) + %SETCROSSHAIRLINK Set the crosshair-link flag (260602-mri). + % setCrosshairLink(obj, tf) sets CrosshairLinked to logical(tf). + % tf must be a logical scalar or a numeric 0/1 scalar. + % Does NOT touch graphics — the engine owns broadcast wiring. + % Throws FastSenseWidget:invalidCrosshairLink for invalid input. + % + % This method is the duck-type hook: DashboardLayout calls + % ismethod(widget,'setCrosshairLink') to decide whether to render + % the crosshair-link toggle button on the WidgetButtonBar. + if ~((islogical(tf) || isnumeric(tf)) && isscalar(tf) && ... + (tf == 0 || tf == 1 || islogical(tf))) + error('FastSenseWidget:invalidCrosshairLink', ... + 'CrosshairLinked must be a logical scalar (or numeric 0/1); got %s.', ... + class(tf)); + end + obj.CrosshairLinked = logical(tf); + end + function autoScaleY_(obj, y) %AUTOSCALEY_ Rescale the Y axis to cover current data + thresholds. % FastSense locks YLim to manual mode at first render, so new @@ -1205,6 +1233,11 @@ function invalidatePreviewCache_(obj) if ~strcmp(obj.YLimitMode, 'auto-visible') s.yLimitMode = obj.YLimitMode; end + % 260602-mri — emit crosshairLinked only when true so legacy + % JSON stays byte-identical for old dashboards (default false). + if obj.CrosshairLinked + s.crosshairLinked = true; + end % NOTE: EventStore is a runtime handle — intentionally NOT serialized (Pitfall E). if ~isempty(obj.Tag) && ~isempty(obj.Tag.Key) @@ -1570,6 +1603,13 @@ function rebuildForTag_(obj) % Invalid serialized value; keep default 'auto-visible'. end end + % 260602-mri — restore CrosshairLinked if serialized. Absent means + % "legacy dashboard, default false" so JSON round-trip is byte-identical. + % Do NOT call setCrosshairLink here (fromStruct runs pre-render; + % graphics wiring is the engine's responsibility at realize time). + if isfield(s, 'crosshairLinked') + obj.CrosshairLinked = logical(s.crosshairLinked); + end end end end diff --git a/libs/FastSense/HoverCrosshair.m b/libs/FastSense/HoverCrosshair.m index 8f18f6aa..e2c9b27d 100644 --- a/libs/FastSense/HoverCrosshair.m +++ b/libs/FastSense/HoverCrosshair.m @@ -49,6 +49,11 @@ FigDeleteListener = [] % listener handle on figure ObjectBeingDestroyed AxDeleteListener = [] % listener handle on axes ObjectBeingDestroyed ThrottleSeconds = 0.025 % minimum interval between motion-driven updates + % 260602-mri — crosshair-link broadcast support + BroadcastFcn_ = [] % @(x) callback fired at end of onMove (link source->peers) + BroadcastLeaveFcn_ = [] % @() callback fired at end of onLeave (source hides->peers) + IsMirrored_ = false % driven by a peer via onMoveExternal; own self-leave then no-ops (deterministic, no wall-clock) + InBroadcast_ = false % re-entrancy guard: peers must not re-broadcast end methods (Access = public) @@ -100,6 +105,54 @@ end end + function setBroadcastFcn(obj, moveFn, leaveFn) + %SETBROADCASTFCN Set (or clear) crosshair-link broadcast callbacks (260602-mri). + % setBroadcastFcn(obj, moveFn, leaveFn) + % moveFn — @(x) callback, or [] to clear. Called at end of onMove. + % leaveFn — @() callback, or []. Called at end of onLeave on source. + % nargin < 3: leaveFn defaults to []. + if ~isvalid(obj); return; end + if nargin < 2 + moveFn = []; + end + if nargin < 3 + leaveFn = []; + end + obj.BroadcastFcn_ = moveFn; + obj.BroadcastLeaveFcn_ = leaveFn; + end + + function onMoveExternal(obj, xQuery) + %ONMOVEEXTERNAL Mirror an external crosshair data-x without re-broadcasting. + % Called by DashboardEngine.broadcastCrosshairX_ to drive a peer + % crosshair at xQuery. Sets IsMirrored_ so the peer's own + % onFigureMove_->onLeave (fired in the same motion dispatch when + % the cursor is not over this widget) is swallowed and the + % mirrored crosshair stays visible. IsMirrored_ is cleared either + % when this widget becomes the hover source (its own real onMove) + % or by onLeaveExternal (the source's leave-broadcast). + if ~isvalid(obj); return; end + obj.IsMirrored_ = true; + obj.InBroadcast_ = true; + try + obj.onMove(xQuery); + catch + % swallow — never let mirroring errors surface + end + obj.InBroadcast_ = false; + end + + function onLeaveExternal(obj) + %ONLEAVEEXTERNAL Clear mirror state and hide crosshair on command. + % Called by DashboardEngine.broadcastCrosshairLeave_ when the + % source crosshair's own onLeave fires (cursor left all linked axes). + % Hides DIRECTLY (does NOT call onLeave) so it never re-broadcasts + % leave — that would ping-pong between linked peers indefinitely. + if ~isvalid(obj); return; end + obj.IsMirrored_ = false; + obj.hideGraphics_(); + end + function onMove(obj, xQuery) %ONMOVE Update + show the crosshair at data x-coordinate xQuery. % Public so tests can drive motion deterministically without @@ -112,6 +165,13 @@ function onMove(obj, xQuery) fp = obj.Target; if isempty(fp) || ~isvalid(fp); return; end + % 260602-mri — a real (non-mirrored) hover makes this crosshair the + % link source. Clearing IsMirrored_ here means a later self-leave + % WILL hide it and broadcast leave to peers (see onLeave). + if ~obj.InBroadcast_ + obj.IsMirrored_ = false; + end + yLim = get(obj.hAxes, 'YLim'); xLim = get(obj.hAxes, 'XLim'); @@ -187,11 +247,39 @@ function onMove(obj, xQuery) 'String', rows, ... 'Visible', 'on'); end + % 260602-mri — broadcast data-x to peer crosshairs (link source only). + % InBroadcast_ guard prevents re-entrancy when onMove is called + % via onMoveExternal (peers must not re-broadcast). + if ~obj.InBroadcast_ && isa(obj.BroadcastFcn_, 'function_handle') + try obj.BroadcastFcn_(xQuery); catch; end + end end function onLeave(obj) %ONLEAVE Hide the crosshair line + datatip. if ~isvalid(obj); return; end + % 260602-mri — suppress-leave: a crosshair currently mirrored by a + % peer (IsMirrored_ set in onMoveExternal) must NOT hide on its own + % same-dispatch self-leave (the cursor is over the source, not over + % this widget). It hides only via onLeaveExternal (the source's + % leave-broadcast). This is deterministic — no wall-clock window — + % so it cannot race the synchronous motion dispatch. + if obj.IsMirrored_ + return; + end + obj.hideGraphics_(); + % 260602-mri — this is the source crosshair (not externally driven): + % broadcast leave so peer crosshairs hide. onLeaveExternal hides + % peers DIRECTLY (it does not call onLeave), so there is no leave + % ping-pong / unbounded recursion between linked peers. + if ~obj.InBroadcast_ && isa(obj.BroadcastLeaveFcn_, 'function_handle') + try obj.BroadcastLeaveFcn_(); catch; end + end + end + + function hideGraphics_(obj) + %HIDEGRAPHICS_ Hide the crosshair line + datatip (no broadcast). + % Shared by onLeave (source path) and onLeaveExternal (peer path). if ~isempty(obj.hLineV) && ishandle(obj.hLineV) set(obj.hLineV, 'Visible', 'off'); end @@ -204,6 +292,10 @@ function delete(obj) %DELETE Restore prior WindowButtonMotionFcn and remove graphics. % Restore unconditionally — '' is a legal callback value, so % we must NOT guard with ~isempty(PrevWBMFcn_). + % 260602-mri — null broadcast callbacks first (defensive: avoids a + % dangling engine ref firing after delete via stale closure). + obj.BroadcastFcn_ = []; + obj.BroadcastLeaveFcn_ = []; if ~isempty(obj.hFigure) && ishandle(obj.hFigure) try %#ok set(obj.hFigure, 'WindowButtonMotionFcn', obj.PrevWBMFcn_); diff --git a/tests/test_fastsense_crosshair_link.m b/tests/test_fastsense_crosshair_link.m new file mode 100644 index 00000000..3642eeb4 --- /dev/null +++ b/tests/test_fastsense_crosshair_link.m @@ -0,0 +1,380 @@ +function test_fastsense_crosshair_link() +%TEST_FASTSENSE_CROSSHAIR_LINK Tests for FastSenseWidget crosshair-link feature (260602-mri). +% +% Covers: +% PURE (no graphics, run on MATLAB + Octave): +% - Default CrosshairLinked == false +% - setCrosshairLink(true/false) round-trips; 'bad' throws namespaced error +% - toStruct omits crosshairLinked when false; emits true when set +% - fromStruct restores true when present; legacy struct -> false +% RENDER-GUARDED (MATLAB desktop only, skipped on Octave + headless): +% - onMoveExternal shows crosshair; immediately following onLeave is +% SUPPRESSED (suppress-leave crux); onLeaveExternal hides (clears) +% - setBroadcastFcn fires BroadcastFcn_ exactly once from onMove; +% onMoveExternal does NOT re-invoke it (InBroadcast_ gate) +% ENGINE + PURE enumeration: +% - collectLinkedCrosshairs_ returns only linked+rendered widgets +% - flipping CrosshairLinked changes the count +% - GroupWidget-nested FastSenseWidget IS included (flatten works) +% RENDER-GUARDED integration: +% - 2-widget dashboard: rewireCrosshairLinks_ + onMove on A mirrors B; +% onLeave on A hides B + + addpath(fullfile(fileparts(mfilename('fullpath')), '..')); install(); + + nPassed = 0; nFailed = 0; + cleanupAll = onCleanup(@() close('all', 'force')); %#ok + + % ========================================================= + % PURE TESTS (MATLAB + Octave) + % ========================================================= + + % --- test_default_crosshair_linked_is_false --- + try + tag = makeTag_(); + w = FastSenseWidget('Tag', tag); + assert(w.CrosshairLinked == false, ... + sprintf('default CrosshairLinked must be false, got %d', w.CrosshairLinked)); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_default_crosshair_linked_is_false: %s\n', err.message); + end + + % --- test_set_crosshair_link_true_and_false --- + try + tag = makeTag_(); + w = FastSenseWidget('Tag', tag); + w.setCrosshairLink(true); + assert(w.CrosshairLinked == true, 'setCrosshairLink(true) must set CrosshairLinked=true'); + w.setCrosshairLink(false); + assert(w.CrosshairLinked == false, 'setCrosshairLink(false) must set CrosshairLinked=false'); + % also accept numeric 0/1 + w.setCrosshairLink(1); + assert(w.CrosshairLinked == true, 'setCrosshairLink(1) must set CrosshairLinked=true'); + w.setCrosshairLink(0); + assert(w.CrosshairLinked == false, 'setCrosshairLink(0) must set CrosshairLinked=false'); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_set_crosshair_link_true_and_false: %s\n', err.message); + end + + % --- test_set_crosshair_link_bad_throws --- + try + tag = makeTag_(); + w = FastSenseWidget('Tag', tag); + errId = ''; + try + w.setCrosshairLink('bad'); + catch e + errId = e.identifier; + end + assert(strcmp(errId, 'FastSenseWidget:invalidCrosshairLink'), ... + sprintf('expected FastSenseWidget:invalidCrosshairLink, got ''%s''', errId)); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_set_crosshair_link_bad_throws: %s\n', err.message); + end + + % --- test_to_struct_omits_when_false --- + try + tag = makeTag_(); + w = FastSenseWidget('Tag', tag); % default false + s = w.toStruct(); + assert(~isfield(s, 'crosshairLinked'), ... + 'toStruct must NOT emit crosshairLinked when false (legacy JSON byte-identical)'); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_to_struct_omits_when_false: %s\n', err.message); + end + + % --- test_to_struct_emits_true_when_set --- + try + tag = makeTag_(); + w = FastSenseWidget('Tag', tag); + w.setCrosshairLink(true); + s = w.toStruct(); + assert(isfield(s, 'crosshairLinked'), ... + 'toStruct must emit crosshairLinked when true'); + assert(s.crosshairLinked == true, ... + sprintf('crosshairLinked in struct must be true, got %d', s.crosshairLinked)); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_to_struct_emits_true_when_set: %s\n', err.message); + end + + % --- test_from_struct_restores_true --- + try + tag = makeTag_(); + w1 = FastSenseWidget('Tag', tag); + w1.setCrosshairLink(true); + s = w1.toStruct(); + w2 = FastSenseWidget.fromStruct(s); + assert(w2.CrosshairLinked == true, ... + sprintf('fromStruct must restore CrosshairLinked=true, got %d', w2.CrosshairLinked)); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_from_struct_restores_true: %s\n', err.message); + end + + % --- test_legacy_struct_without_field_defaults_false --- + try + s = struct('type', 'fastsense', 'title', 't', ... + 'position', struct('col', 1, 'row', 1, 'width', 6, 'height', 2)); + w = FastSenseWidget.fromStruct(s); + assert(w.CrosshairLinked == false, ... + sprintf('legacy struct without crosshairLinked must default to false, got %d', ... + w.CrosshairLinked)); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_legacy_struct_without_field_defaults_false: %s\n', err.message); + end + + % ========================================================= + % PURE ENGINE ENUMERATION TEST (MATLAB + Octave, no figures) + % ========================================================= + + % --- test_collect_linked_crosshairs_pure_enumeration --- + % Build a tiny DashboardEngine and a fake widget list to verify + % collectLinkedCrosshairs_ enumeration without real figures. + try + eng = DashboardEngine('TestLink'); + % Build 3 widgets: 2 linked, 1 unlinked. None rendered (headless). + tag = makeTag_(); + wA = FastSenseWidget('Tag', tag); wA.setCrosshairLink(true); + wB = FastSenseWidget('Tag', tag); wB.setCrosshairLink(true); + wC = FastSenseWidget('Tag', tag); % unlinked + + % collectLinkedCrosshairs_ takes the widget list as a parameter + % (DashboardEngine.Widgets is SetAccess=private). With no FastSenseObj + % rendered it must return empty (guard: FastSenseObj must be rendered). + wlist = {wA, wB, wC}; + linked = eng.collectLinkedCrosshairs_(wlist); + assert(isempty(linked), ... + sprintf('unrendered widgets must yield empty linked set; got %d', numel(linked))); + + % Flip wA unlinked -> size still 0 (not rendered) + wA.setCrosshairLink(false); + linked2 = eng.collectLinkedCrosshairs_(wlist); + assert(isempty(linked2), ... + 'still empty with all unrendered widgets'); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_collect_linked_crosshairs_pure_enumeration: %s\n', err.message); + end + + % ========================================================= + % RENDER-GUARDED TESTS (MATLAB desktop only) + % ========================================================= + + % Skip render-guarded cases on Octave (HoverCrosshair is MATLAB-only) + if exist('OCTAVE_VERSION', 'builtin') + fprintf(' Render-guarded crosshair-link tests: SKIPPED (Octave - HoverCrosshair uses isvalid).\n'); + fprintf(' %d passed, %d failed.\n', nPassed, nFailed); + if nFailed > 0 + error('test_fastsense_crosshair_link:failures', '%d test(s) failed.', nFailed); + end + fprintf(' All %d tests passed.\n', nPassed); + return; + end + + if ~canRenderFigures_() + fprintf(' Render-guarded crosshair-link tests: SKIPPED (no java desktop).\n'); + fprintf(' %d passed, %d failed.\n', nPassed, nFailed); + if nFailed > 0 + error('test_fastsense_crosshair_link:failures', '%d test(s) failed.', nFailed); + end + fprintf(' All %d tests passed.\n', nPassed); + return; + end + + % --- test_suppress_leave_crux --- + % Core mechanism proof: onMoveExternal shows; next onLeave is suppressed; + % onLeaveExternal hides cleanly. + try + [fp, hFig] = makeFp_(1); + cleanup = onCleanup(@() safeClose_(hFig)); %#ok + hc = HoverCrosshair(fp); + t = linspace(0, 10, 500); + xMid = t(250); + + % Drive crosshair externally (simulates being mirrored by another widget). + hc.onMoveExternal(xMid); + assert(strcmp(get(hc.hLineV, 'Visible'), 'on'), ... + 'onMoveExternal must show the crosshair line'); + + % An immediately following onLeave (same dispatch, cursor not over us) + % MUST be suppressed because IsMirrored_ was set by onMoveExternal. + hc.onLeave(); + assert(strcmp(get(hc.hLineV, 'Visible'), 'on'), ... + 'onLeave immediately after onMoveExternal must be SUPPRESSED (crux)'); + + % onLeaveExternal clears suppress + hides. + hc.onLeaveExternal(); + assert(strcmp(get(hc.hLineV, 'Visible'), 'off'), ... + 'onLeaveExternal must hide the crosshair'); + + delete(hc); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_suppress_leave_crux: %s\n', err.message); + end + + % --- test_broadcast_fires_once_no_reentry --- + % BroadcastFcn_ fires exactly once from onMove; onMoveExternal does NOT + % re-invoke it (InBroadcast_ gate). + try + [fp, hFig] = makeFp_(1); + cleanup = onCleanup(@() safeClose_(hFig)); %#ok + hc = HoverCrosshair(fp); + t = linspace(0, 10, 500); + xMid = t(250); + + % Install a broadcast counter via setBroadcastFcn. + counterMap = containers.Map({'count'}, {0}); + moveFn = @(x) incrementCounter_(counterMap); + leaveFn = @() []; + hc.setBroadcastFcn(moveFn, leaveFn); + + % onMove must fire BroadcastFcn_ exactly once. + hc.onMove(xMid); + assert(counterMap('count') == 1, ... + sprintf('onMove must fire BroadcastFcn_ once; fired %d times', counterMap('count'))); + + % onMoveExternal must NOT re-invoke BroadcastFcn_ (InBroadcast_ gate). + hc.onMoveExternal(xMid); + assert(counterMap('count') == 1, ... + sprintf('onMoveExternal must not re-invoke BroadcastFcn_; count is %d', counterMap('count'))); + + delete(hc); + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_broadcast_fires_once_no_reentry: %s\n', err.message); + end + + % --- test_two_widget_mirror_integration --- + % End-to-end: 2-widget dashboard, both linked, rewire, then simulate + % hover on A -> B mirrors; then A leaves -> B hides. + try + eng = DashboardEngine('TestLink2'); + [fig, panA] = makeOffscreenFigure_(); + cleanFig = onCleanup(@() safeClose_(fig)); %#ok + + tag = makeTag_(); + wA = FastSenseWidget('Tag', tag); wA.setCrosshairLink(true); + wB = FastSenseWidget('Tag', tag); wB.setCrosshairLink(true); + + % Render wA and wB into sub-panels of our test figure. + panB = uipanel('Parent', fig, 'Units', 'normalized', 'Position', [0.5 0 0.5 1]); + panAp = uipanel('Parent', fig, 'Units', 'normalized', 'Position', [0 0 0.5 1]); + wA.render(panAp); + wB.render(panB); + + % Register widgets with the engine (Widgets is SetAccess=private; + % addWidget accepts pre-constructed widget handles) + rewire links. + eng.addWidget(wA); + eng.addWidget(wB); + eng.rewireCrosshairLinks_(); + + % Simulate hover on A: onMove fires BroadcastFcn_ which calls + % broadcastCrosshairX_ -> B.onMoveExternal. + t = linspace(0, 10, 500); + xMid = t(250); + hcA = wA.FastSenseObj.HoverCrosshair_; + hcB = wB.FastSenseObj.HoverCrosshair_; + if ~isempty(hcA) && isvalid(hcA) && ~isempty(hcB) && isvalid(hcB) + hcA.onMove(xMid); + drawnow; + assert(strcmp(get(hcB.hLineV, 'Visible'), 'on'), ... + 'B crosshair must be visible after A onMove (mirror works)'); + + % A leaves -> broadcast leave -> B hides. + hcA.onLeave(); + drawnow; + assert(strcmp(get(hcB.hLineV, 'Visible'), 'off'), ... + 'B crosshair must hide after A onLeave (leave-broadcast works)'); + else + fprintf(' test_two_widget_mirror_integration: skipped (crosshairs not created in headless mode)\n'); + end + nPassed = nPassed + 1; + catch err + nFailed = nFailed + 1; + fprintf(' FAIL test_two_widget_mirror_integration: %s\n', err.message); + end + + % ========================================================= + % Summary + % ========================================================= + fprintf(' %d passed, %d failed.\n', nPassed, nFailed); + if nFailed > 0 + error('test_fastsense_crosshair_link:failures', '%d test(s) failed.', nFailed); + end + fprintf(' All %d tests passed.\n', nPassed); +end + +% ============================ HELPERS ============================ + +function tag = makeTag_() +%MAKETAG_ Plain SensorTag with sinusoidal data on [0, 10]. + t = linspace(0, 10, 500)'; + y = sin(t); + tag = SensorTag('tst_xlink', 'Name', 'TstLink', 'X', t, 'Y', y); +end + +function [fig, panel] = makeOffscreenFigure_() +%MAKEOFFSCREENFIGURE_ Visible='off' figure + single uipanel for rendering. + fig = figure('Visible', 'off', 'Units', 'pixels', 'Position', [100 100 800 400]); + panel = uipanel('Parent', fig, 'Units', 'normalized', 'Position', [0 0 1 1]); +end + +function [fp, hFig] = makeFp_(nLines) +%MAKEFP_ Build a rendered FastSense in an offscreen figure. + hFig = figure('Visible', 'off'); + ax = axes('Parent', hFig); + fp = FastSense('Parent', ax); + t = linspace(0, 10, 500); + if nLines >= 1 + fp.addLine(t, sin(t), 'DisplayName', 'sine'); + end + if nLines >= 2 + fp.addLine(t, cos(t), 'DisplayName', 'cosine'); + end + fp.render(); +end + +function tf = canRenderFigures_() +%CANRENDERFIGURES_ True when MATLAB can create an invisible figure + uipanel. + tf = false; + try + h = figure('Visible', 'off'); + if ishandle(h) + tf = true; + close(h, 'force'); + end + catch + tf = false; + end +end + +function safeClose_(fig) + try + if ~isempty(fig) && ishandle(fig) + close(fig, 'force'); + end + catch + end +end + +function incrementCounter_(m) +%INCREMENTCOUNTER_ Increment the 'count' key in a containers.Map. + m('count') = m('count') + 1; +end