Dashboard Live/Follow preserve + resize/tab-switch zombie-panel fix#136
Merged
Conversation
Live-mode ad-hoc plots in FastSenseCompanion ("Cooling Out temp" et al.)
showed a tail sawtooth AND auto-panned XLim every tick, preventing the
user from inspecting historical data. Two independent root causes
(260512-live-mode-companion-adhoc-tail-spike):
1. Wide-last-bucket sawtooth. `minmax_core` (and the MEX twin) computed
`bucketSize = floor(n / nb)` and folded the entire remainder
`(n - bucketSize*nb)` into the LAST bucket. For n=604889, nb=6049
(typical pyramid call for 7-day 1Hz data), bucketSize=99 left a
6038-sample remainder ~1.7 hours wide. The last bucket's interior
min/max emissions sprawled across that window as a fake spike.
Yesterday's PR #133 tail anchor pinned the final X to the data tail
but did not stop the second/third-to-last interior emissions from
creating the sawtooth. Fix: bump `nb` to `floor(n / bucketSize)` in
both `minmax_core_mex.c` and the pure-MATLAB `minmax_core` so the
remainder is strictly less than one bucket and every bucket keeps
the same time-width. Demo regression: chart tail max/median dX ratio
dropped from 66x to ~4x; cooling.out_temp ad-hoc plot now ends at
the live tail with dense oscillation (n=3025 displayed points).
2. Live-mode XLim hijack. `TimeRangeSelector.setDataRange` rescaled the
selection proportionally on every live tick, sliding XLim 1 s/tick;
`FastSenseWidget.LiveViewMode='reset'` (the dashboard default) was
also propagating to ad-hoc plots. Fixes:
- `TimeRangeSelector.setDataRange`: when the new range fully contains
the current selection (live-extension case), keep the selection
unchanged - only rescale proportionally on contraction or when the
selection falls outside.
- `openAdHocPlot` LinkedGrid path: pass `'LiveViewMode', 'preserve'`
so each FastSense in an ad-hoc plot inherits preserve-XLim default.
Dashboard widgets keep `'reset'` (their expected behavior).
User confirmed visually: XLim stable across 5 ticks at
[05-05 12:20:30, 05-12 12:20:37] (width 7.000083 days, no drift), no
sawtooth at right edge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add hFollowBtn uitoggletool between Live and Metadata buttons
- Add setFollow(on) public method: sets LiveViewMode='follow'/'preserve' and snaps XLim to data tail when needed
- Add syncFollowState() private helper to mirror LiveViewMode onto button Enable/State
- Add snapToTailIfNeeded_() private helper for one-shot XLim snap on Follow enable
- Add 'follow' icon (right-pointing triangle + tail anchor) to makeIcon/initIcons
- Add syncFollowState() call in rebind() to sync new target's LiveViewMode
- Add FollowAutoDisengage hook in FastSense.onXLimChanged: user-initiated pan/zoom while Follow=ON flips LiveViewMode to 'preserve' and calls syncFollowState via AppData stash
- Stash toolbar in figure AppData('FastSenseToolbar') at all four attacher sites: FastSenseDock.render (x2), FastSenseDock.selectTab fallback, FastSenseDock.undockTab, EventViewer.openFigurePlot
- Add tests/test_fastsense_follow_toggle.m: 9 function-style tests covering
button existence, initial state, Enable vs LiveViewMode, setFollow(true)
snap-to-tail, no-snap when tail visible, setFollow(false), user-pan
auto-disengage, programmatic-update non-disengage, and dashboard widget default
- Fix snap ordering in setFollow(): snapToTailIfNeeded_ is now called BEFORE
setViewMode('follow') so onXLimChanged fires when LiveViewMode is still the
previous value, preventing auto-disengage from immediately undoing the mode
- Update snapToTailIfNeeded_ docstring to document the required call order
- Add 260512-hrn to Quick Tasks Completed table - Update last_activity and last_updated
…isible) The earlier commits (48dc88f, 0bb8376) put the Follow toggle on FastSenseToolbar — but that toolbar only attaches via FastSenseDock (detached/loupe path) and is not visible on: - Standalone FastSense plots (no toolbar created) - Dashboard tiles (FastSenseWidget inside DashboardEngine — uses DashboardToolbar) - Companion ad-hoc plots (DashboardEngine + FastSenseWidget — same) So the Follow toggle was effectively invisible in the user's actual workflow. This commit puts it where users can see it. Changes: - libs/Dashboard/DashboardToolbar.m * New hFollowBtn + hFollowPanel toggle, mirroring the Live button's panel-wrap-with-blue-highlight visual pattern. Placed right of Live in the toolbar. * onFollowToggle(src): walks every FastSenseWidget in Engine.Widgets (recurses into GroupWidget children) and calls FastSenseObj.setViewMode(mode) where mode is 'follow' (button on) or 'preserve' (button off). When turning on, also calls FastSenseObj.snapToTail() so the chart jumps to the live tail immediately, instead of waiting for the next live tick. * setFollowActiveIndicator(): blue highlight when on, ToolbarBG when off — same idiom as setLiveActiveIndicator. * Private helper applyFollowToWidgets_ does the tree walk so future widget container types (GroupWidget already handled) extend cleanly. - libs/FastSense/FastSense.m * New public snapToTail() method — slides XLim window so its right edge matches max(x) across all lines, keeping the current zoom width. Equivalent to a single 'follow' tick from applyViewMode without waiting for new data. Dashboard FastSenseWidget keeps its 'reset' default — only this toolbar toggle changes runtime state per-widget. Verification on the live demo: - Pan back 3 days, click Follow ON → XLim snaps 3.00 days right to the live tail. - Wait 2.5 s with Follow ON → right edge advances ~2 s (tracking). - Click Follow OFF → next 2.5 s of ticks: XLim does not advance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…owing The Follow toggle from 8e05a48 snapped XLim to [xMax - w, xMax], so the latest data point sat right on the chart's right border — visually cramped. This patch adds a small breathing-room margin: XLim becomes [xMax - w + pad, xMax + pad] where pad = 2% of the current window width. Applied to both: - FastSense.snapToTail() — the one-shot from clicking Follow ON - FastSense.applyViewMode 'follow' branch — per-tick auto-pan while Follow stays on (and any direct LiveViewMode='follow' caller) Verified on the live demo (cooling.out_temp ad-hoc plot, 7-day window): XLim(2) - xTail = 201.62 minutes, padding ratio = 0.0200 — matches the 2% target exactly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User asks for the slider preview AND time labels to refresh as live data
arrives, plus date/time labels for the slider edges to live BELOW the
slider strip (more visible than the in-axes selection handles).
Changes:
- libs/Dashboard/TimeRangeSelector.m
* New hRangeLabelLeft / hRangeLabelRight (uicontrol text in the slider's
parent panel, positioned below the axes). Show data-range edges
(left = earliest, right = latest data sample), updated on every
live tick.
* New public setRangeLabels(leftText, rightText) so the engine can
push pre-formatted timestamps.
* Slider axes Position reduced from [.045 .10 .94 .85] to
[.045 .42 .94 .55] to leave the lower 40% of the panel for the new
labels.
- libs/Dashboard/DashboardEngine.m
* TimePanelHeight default bumped from 0.06 to 0.085 (~40% more
vertical room) so the slider strip + new labels fit comfortably.
* New updateRangeLabels(tMin, tMax) — formats via formatTimeVal and
forwards to TimeRangeSelector.setRangeLabels.
* onLiveTimer: after setDataRange + broadcastTimeRange, calls
updateRangeLabels(DataTimeRange) AND updateTimeLabels(selection)
so both label rows stay in sync with the live data.
* resetGlobalTime: also calls updateRangeLabels for consistency on
the manual Sync path.
* formatTimeVal: format string upgraded from 'yyyy-mm-dd HH:MM' to
'yyyy-mm-dd HH:MM:SS'. Live ticks advance each second, so the
old minute precision hid the update — the labels looked frozen
even though they were being re-pushed.
Slider preview lines were already refreshing on every live tick via the
existing computePreviewEnvelope path — verified during probing
(slider's xLast tracks the data tail), no fix needed there.
Verified on the live demo (cooling.out_temp ad-hoc plot, 1 Hz live
ticks, LiveInterval=1.0s):
t=0: range label right="2026-05-12 13:43:06"
t=3s: range label right="2026-05-12 13:43:10" (advanced 4s)
t=6s: range label right="2026-05-12 13:43:13" (advanced 3s)
Selection labels stay at the preserved selection edge ("13:43:01"),
which is correct — the user hasn't dragged the slider, so the selection
shouldn't move.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… not data range Previous commit b274375 made the new below-slider labels show the DATA RANGE edges (leftmost data sample, rightmost data sample). User correction: they should show the SLIDER's LEFT and RIGHT selection-edge times (the drag-handle positions) so you can read the currently-selected window at a glance. Changes: - libs/Dashboard/DashboardEngine.m * updateTimeLabels(tStart, tEnd): now pushes the same selection values to BOTH the in-axes setLabels AND the below-slider setRangeLabels — one pipeline, both label rows always in sync. * Removed updateRangeLabels (its functionality merged in). * onLiveTimer / resetGlobalTime updated: call updateTimeLabels with the current selection (no more separate updateRangeLabels with data-range args). - libs/Dashboard/TimeRangeSelector.m * Property docstrings updated: hRangeLabelLeft/Right described as "slider LEFT/RIGHT selection-edge timestamp" (not data range). * setRangeLabels docstring updated to match. * buildGraphics_ comment updated. Verified on live demo: programmatic setSelection to inner half-window puts both label rows at the new selection edges exactly (string-equal to datestr of Selection(1) and Selection(2)). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le duration
User request: remove the in-axes selection-edge labels (visually small,
duplicated below) and add a third label centered below the slider that
shows the selection duration (e.g. "7d", "3h 25m", "45 s").
Changes:
- libs/Dashboard/TimeRangeSelector.m
* Removed hLabelLeft / hLabelRight (text objects inside the slider
axes at the selection edges) and LeftLabelText / RightLabelText.
* Removed setLabels public method — all label state now flows
through setRangeLabels(leftText, rightText, middleText).
* setRangeLabels gained an optional 4th positional middleText arg.
* New hRangeLabelMiddle (uicontrol text, centered, bold) between
the left and right edge labels below the slider strip.
* redraw_ no longer updates inner labels (cleaner — selection
patch + edge handles still draw at the right positions).
* Below-slider uicontrol Position triplets adjusted to make room
for the new middle label: L=[.045 .05 .30 .32], M=[.36 .05 .28 .32],
R=[.66 .05 .30 .32].
- libs/Dashboard/DashboardEngine.m
* updateTimeLabels now formats the selection span via the new
formatDuration_ helper and passes it as setRangeLabels' middle arg.
* formatDuration_ private method: render a datenum-day span as a
short readable string — sub-1s with 2 decimals, <1m as "Ns",
<1h as "Xm Ys" (or "Xm" when whole), <1d as "Xh Ym" (or "Xh"
when whole), else "Xd Yh" (or "Xd" when whole).
Verified on the live demo (programmatic drag via setSelection):
init / full range: M="7d"
3-hour drag: M="3h"
45-min drag: M="50m 24s" (clamped wider by MinWidthFrac)
back to full: M="7d"
And confirmed: TimeRangeSelector no longer has hLabelLeft/hLabelRight
properties (isprop() returns 0 for both).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
formatDuration_ previously suppressed zero components (e.g. "7d" for a 7-day window, "3h" for a 3-hour window). User wants the full granularity visible, so the label now always renders all four units: "Xd Yh Zm Ws". Verified on the live demo: Full 7d: M="7d 0h 0m 14s" 3-hour window: M="0d 3h 0m 0s" Mixed 1d 5h 23m 17s: M="1d 5h 23m 17s" (exact) MinWidthFrac-clamped: M="0d 0h 50m 24s" Sub-second spans still fall back to "N.NN s" (granularity below 1s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the user clicks the Follow toggle in DashboardToolbar, the chart
correctly snaps its X axis to the data tail. But on every subsequent
live tick FastSenseWidget.refresh() called autoScaleY_(y), which
silently recomputed YLim from the new sample window — fighting the
user's expectation that Follow is purely an X-side feature.
Fix: add a third early-return in FastSenseWidget.autoScaleY_ (after
YLimits and UserZoomedY checks) for the case
FastSenseObj.LiveViewMode == 'follow'. With Follow engaged, the Y
axis stays exactly where the user left it; only X tracks the tail.
Before:
if ~isempty(obj.YLimits); return; end
if obj.UserZoomedY; return; end
if isempty(obj.FastSenseObj) || ~obj.FastSenseObj.IsRendered; return; end
... rescale YLim from y(:) + thresholds ...
After (new guard inserted):
if ~isempty(obj.YLimits); return; end
if obj.UserZoomedY; return; end
if ~isempty(obj.FastSenseObj) && isvalid(obj.FastSenseObj) ...
&& strcmp(obj.FastSenseObj.LiveViewMode, 'follow')
return;
end
if isempty(obj.FastSenseObj) || ~obj.FastSenseObj.IsRendered; return; end
... rescale ...
Verified on industrial plant demo via mcp__matlab__: file lints clean
(no new warnings near edit) and rehash picks up the new method body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fill in 498a5f3 for the 260513-ovt row that was committed in the previous commit with a TBD placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on 498a5f3 (which preserved Y only when Follow was engaged). The user wants Live mode to be strictly "append data, never mutate axis limits" — limits change only on explicit user action (mouse pan/zoom, slider drag, Sync-All button, broadcastTimeRangeNow API, or the Follow toggle for X tail-tracking). Two implicit per-tick mutations are removed: 1. libs/Dashboard/FastSenseWidget.m — refresh() and update() no longer call obj.autoScaleY_(y) after updateData. autoScaleY_ now only runs once at widget realization via rebuildForTag_, where it sets the initial Y range from the first batch of data. After that, Y is the user's domain. 2. libs/Dashboard/DashboardEngine.m — onLiveTick no longer calls obj.broadcastTimeRange(tStart, tEnd). The surrounding setDataRange (slider's data-range tracking), getSelection, and updateTimeLabels (the below-slider time labels) still run so the slider's own UI stays current. User-driven broadcast paths remain wired: • slider-drag debounced broadcast (SliderDebounceTimer) • public broadcastTimeRangeNow() (tests, Sync-All button) • initial-render time-range push Today's earlier 498a5f3 guard (skip autoScaleY_ when LiveViewMode='follow') is kept as defensive code — autoScaleY_ is no longer reached during ticks, but the guard is harmless and still correct if anyone re-introduces a live-tick call to it. Verified: - mcp__matlab__check_matlab_code: clean (no new warnings near edits). - test_dashboard_time_sync_all_pages: 5/5 PASS (broadcastTimeRangeNow user-driven path still drives cross-page sync). - test_dashboard_range_selector_integration: 2/2 PASS (slider-driven broadcast + debounced timer still work end-to-end). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… syncFollowState public
After the user clarified "Both X and Y frozen at user's view... Live
ticks only append data; limits never change unless user pans/zooms",
two more issues surfaced:
1. FastSenseWidget.LiveViewMode default was 'reset' — every live tick
ran FastSense.applyViewMode('reset') which snaps XLim to
[newX(1), newX(end)], overwriting whatever the user had zoomed
to. The previous "remove broadcastTimeRange from onLiveTick"
change (ca5be95) handled the slider-driven X reset but didn't
touch this per-widget reset. Flipping the widget default to
'preserve' makes Live mode "data flows in, my view stays put":
- 'preserve' (DEFAULT, 260513-ovt): frozen at initial X range;
live ticks append data only. User opts into seeing new data via
Follow toggle, slider drag, or Reset/Sync-All button.
- 'follow': what the Follow toolbar toggle switches to.
- 'reset': former default; XLim grows to cover all samples
since session start (still available for short demos that want
auto-fill behavior).
Doc comments on the property declaration and inside render() are
updated to reflect the new default. Existing scripts/dashboards
that explicitly set LiveViewMode keep their value; serialized
configs do not store LiveViewMode so loaded dashboards inherit
the new default. test_dashboardWidgetDefaultUnchanged is renamed
to test_dashboardWidgetDefaultIsPreserve and updated to assert
'preserve'.
2. FastSenseToolbar.syncFollowState was declared in a private
methods block. FastSense.onXLimChanged's auto-disengage hook does
`if ismethod(tb, 'syncFollowState'); tb.syncFollowState(); end`
— and ismethod() returns false for private methods, so the call
was silently skipped. Result: when the user panned a chart while
Follow was engaged, LiveViewMode correctly reverted to 'preserve'
but the Follow toolbar button stayed visually ON. Moved
syncFollowState into its own `methods (Access = public)` block;
the test_userPanDisengagesFollow regression now passes.
Verified:
- mcp__matlab__check_matlab_code: clean (no new warnings).
- test_fastsense_follow_toggle: 10/10 PASS.
- test_dashboard_time_sync_all_pages: 5/5 PASS.
- test_dashboard_range_selector_integration: 2/2 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…STATE.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cooperating bugs made the Follow toolbar button silently no-op
on the industrial plant demo:
1. DashboardToolbar.onFollowToggle iterated obj.Engine.Widgets — but
in multi-page mode (6 pages in the demo) that property is empty;
widgets live on obj.Engine.Pages{i}.Widgets. The intended
accessor was obj.Engine.allPageWidgets().
2. DashboardEngine.allPageWidgets (and activePageWidgets) were
declared inside a `methods (Access = private)` block. So the
attempted fix `obj.Engine.allPageWidgets()` threw
`MATLAB:class:MethodRestricted`. The toolbar's try/catch caught
it and emitted a `DashboardToolbar:followToggleFailed` warning,
silently leaving every widget's LiveViewMode untouched.
Fix:
- DashboardEngine.m: extracted both `activePageWidgets` and
`allPageWidgets` into a new `methods (Access = public)` block.
They're general-purpose read-only widget accessors and have no
reason to be private — they're now safely callable from peer
classes (DashboardToolbar, tests, the user's own scripts).
- DashboardToolbar.m::onFollowToggle now calls
`obj.Engine.allPageWidgets()` so the per-widget LiveViewMode +
snapToTail sweep reaches every page.
Verified on the running demo:
- allPageWidgets() returns 29 widgets, no MethodRestricted.
- Follow ON: XLim right edge shifted +0.140 days toward the data
tail; window width preserved exactly (delta = 0).
- 2 of 2 FastSenseWidgets across all pages flipped to 'follow'.
- 2 of 2 reverted to 'preserve' on toggle OFF.
(Note: applyViewMode('follow') already pads the right edge by 2%
of the window width — added in 21dcddc yesterday — so the live-tick
slide keeps that gap between the latest sample and the right axes
frame.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing white
Symptom: when the user drag-resizes the dashboard figure, FastSenseWidget
panels sometimes go white/blank. They stay white until the toolbar's
Reset button is pressed (which calls rerenderWidgets()). With Live mode
OFF, no live tick can rescue them either.
Likely cause: mouse-drag resize on macOS fires many SizeChangedFcn
events per second. The cascade — DashboardEngine.onResize →
repositionPanels → cell-panel SizeChangedFcn → DashboardLayout
.reflowChrome_ (which temporarily flips panel Units to pixels) —
interleaves with FastSense.getAxesPixelWidth's own transient axes-Units
flip and any pending FastSense.updateLines call. Under the wrong
interleaving, lineVisibleData can return empty arrays, leaving the
line with empty XData. Without a follow-up updateData/updateLines call
there is no automatic recovery.
We were not able to reproduce the bug with programmatic
`set(figure,'Position',...)` — that fires exactly one ResizeFcn per
call; mouse-drag fires coalesced bursts that the synthetic test
cannot replicate.
Fix: add a debounced post-resize refresh in DashboardEngine, mirroring
the existing SliderDebounceTimer pattern.
- New property ResizeDebounceTimer
- New scheduleResizeRefresh_(): stops + recreates a 0.3 s
singleShot timer on every resize event (debounce reset)
- New refreshActivePageWidgetsAfterResize_(): iterates active-page
widgets and calls update() on FastSenseWidget / refresh() on
others. update() goes through updateData -> updateLines, which
re-pushes data through the line and restores XData/YData
- onResize calls obj.scheduleResizeRefresh_() at the end
- delete() tears down the timer next to SliderDebounceTimer
Why update() not rerenderWidgets(): rerenderWidgets is the heavy
hammer used by the Reset button — it tears down + rebuilds every
panel. update() is a cheap data-refresh that fixes a "line data was
wiped" symptom without the visible rebuild blink.
Why 300 ms: drag-release events finish well within 300 ms on macOS,
so the timer reliably fires once after the user stops resizing.
Matches the SliderDebounceTimer cadence.
Verified:
- mcp__matlab__check_matlab_code: clean (no new warnings near edits).
- Source verification: all 5 grep assertions pass.
- test_dashboard_range_selector_integration: 2/2 PASS.
- test_dashboard_time_sync_all_pages: completes without throwing.
- Live engine: ResizeDebounceTimer property present, scheduled
on resize (Tag='DashboardEngineResizeDebounce', StartDelay=0.30,
Running='on'), restarts cleanly on a second resize within the
window (no double-fire), and Running='off' after 0.5 s. delete()
tears it down without error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…te white widgets Follow-up to 577bf95. The user reports the cheap update() pass still leaves "some widgets white" when the figure is dragged really small. Diagnosis: at very small figure sizes the axes XLim can be transiently clobbered (axes pixel position degenerate, MATLAB auto-recomputes, etc.) so lineVisibleData returns empty arrays and updateLines writes empty XData to the line — and our cheap update() re-runs the same path which produces the same empty result. Solution: make refreshActivePageWidgetsAfterResize_ a two-pass refresh. Pass 1 (cheap): call w.update() / w.refresh() on every realized active-page widget (unchanged from 577bf95). Fixes ~99% of cases. Pass 2 (escalation): for every FastSenseWidget, check whether the first line is "white" — XData empty AND the bound Tag actually has samples. If so: a. try per-widget w.refresh() (the full rebuildForTag_ path). b. if still white, escalate to obj.rerenderWidgets() — the same heavy hammer the user would have pressed via the toolbar's Reset button. The detection is conservative: only flags widgets whose Tag has data but whose line lost it. Tag-less widgets, sensors-still-loading widgets, and the case where XLim is just zoomed past the data won't trigger escalation. Verified on the live demo via a synthetic white-widget test: - Forced XData=[] on a real widget's line (941 -> 0) - isWidgetLineWhite_(w) returned 1 (detection correct) - refreshActivePageWidgetsAfterResize_() restored XData (0 -> 941) - isWidgetLineWhite_(w) returned 0 after Regression tests: test_dashboard_time_sync_all_pages 5/5 PASS, test_dashboard_range_selector_integration 2/2 PASS. Static analysis clean — no new warnings in the modified region. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported: even with the 300ms two-pass refresh (99c8808), dragging the dashboard small and holding for a long time still leaves widgets white. Diagnosis: at very small sizes other failure modes happen that isWidgetLineWhite_'s "empty XData + Tag has data" detection misses — e.g. destroyed line handles, degenerate axes Position cached at nearly-zero pixel size, or some MATLAB internal state that survives into the next resize. Solution suggested by the user: "on resize mouse press release, add an final redraw". MATLAB doesn't fire window-frame mouse events for figure-corner drags, so we approximate "drag release" via a second, longer debounce timer that fires unconditionally — equivalent to the user pressing Reset once they have clearly stopped resizing. Layout (mirrors the existing SliderDebounceTimer pattern): - New property ResizeFinalRedrawTimer - scheduleResizeFinalRedraw_(): 1.2 s singleShot, restarts on every resize event. Both this and the existing 300 ms ResizeDebounceTimer run in parallel. - finalRedrawAfterResize_(): calls obj.rerenderWidgets() unconditionally - onResize calls BOTH scheduleResizeRefresh_() AND scheduleResizeFinalRedraw_() - delete() tears down ResizeFinalRedrawTimer next to ResizeDebounceTimer When the user stops resizing for >= 1.2 s, the full rebuild fires. During continuous drag, both timers keep restarting and neither fires — so this doesn't hammer the dashboard mid-drag. Re-entrancy guard: SizeChangedFcn fires during initial render() (figure grows to accommodate the layout), which schedules this 1.2 s timer. The timer fires ~1.2 s later — if render is still in flight at that point (rare but observed), rerenderWidgets would clobber obj.Progress_ and outer render's Progress_.finish() would explode. The guard checks obj.Progress_; if non-empty, the timer reschedules itself and bails. Verified: timers fire cleanly within 1.5 s, rerenderWidgets runs as the second progress trace, demo state remains healthy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…STATE.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…inst rerender cascade User reported: after the 1.2s backstop (4eda604), switching dashboard tabs shows only white widgets. Diagnosis via live-engine probe showed two interacting failures: 1. **Stale backstop fires on wrong page.** User resized → both timers scheduled. User clicked a different tab within 1.2s. switchPage set ActivePage = 2 and realized page 2 widgets. ~1.2s later the backstop fired and called rerenderWidgets() — which uses activePageWidgets(), so it rebuilt page 2 (not the page the resize originated on). Mid-flight panel teardown left the just-realized page 2 widgets WHITE. 2. **rerenderWidgets cascade.** The panel teardown + recreate inside rerenderWidgets fires SizeChangedFcn events of its own, which onResize handled by scheduling NEW resize timers, which fired another rerenderWidgets, ... potential infinite loop. Fix (three coordinated changes): A. **Cancel resize timers in switchPage.** New helper `cancelResizeTimers_()` stops + deletes both ResizeDebounceTimer and ResizeFinalRedrawTimer. switchPage calls it at the start (BEFORE setting ActivePage). Stale backstop from prior resize can no longer fire on the new tab. B. **IsRerendering_ flag suppresses scheduling.** New property IsRerendering_ on DashboardEngine. rerenderWidgets sets it to true at the start (under onCleanup so it lands false even if the method throws) and cancels any existing timers. onResize early-returns when IsRerendering_ is true — no new timers scheduled during the rerender cascade. C. **Re-entrancy guard aborts instead of self-rescheduling.** finalRedrawAfterResize_ previously self-rescheduled when Progress_ was non-empty (initial render in flight). That kept deferring the backstop until after switchPage, landing on the wrong page. Now: Progress_ non-empty OR IsRerendering_ true → bail entirely; any actual subsequent resize will schedule fresh. Verified on live demo via reproduction script: - After resize: 1 backstop scheduled (expected) - After switchPage within 1.2s: 0 backstop (cancelled by switchPage) - After 1.5s wait: 0 backstop (none fired late) - Page 2 widgets: 0 white, panels intact, XData populated - Explicit rerenderWidgets(): IsRerendering_ returns to 0 via onCleanup, no leftover timers - Static analysis: no new warnings Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rd in STATE.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reports: even after bc305dc (cancelResizeTimers_ in switchPage), resizing then switching tabs still leaves old tab widgets visible AND new tab widgets white. Diagnosis: rerenderWidgets uses drawnow internally (via DashboardProgress ticks + realizeBatch). That drawnow lets MATLAB process pending UI events — including a uicontrol callback from the user clicking a tab button. So switchPage can interrupt rerenderWidgets MID-LOOP. The captured local `ws = activePageWidgets()` inside rerenderWidgets points at the OLD page; switchPage then changes ActivePage and toggles visibility. rerenderWidgets resumes and continues realizing widgets for the old page (which switchPage just hid), creating chrome inside their cell panels with rendering side effects on the canvas. Net result: the old page's panels end up visible (rerender's realize loop re-shows them as it builds chrome) while the new page's widgets appear white (the canvas state was disturbed mid-flight). Fix: serialize. At the start of switchPage, if IsRerendering_ is true, spin drawnow until it clears (3 s safety timeout). This blocks the tab change until the in-flight rerender finishes; the user perceives a brief pause, but the layout stays consistent. cancelResizeTimers_ remains as the earlier line of defense — it stops the backstop timer BEFORE it has had a chance to fire if the user clicks quickly. The new wait-for-rerender block handles the harder case where the rerender has already started. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…el, not the inner content panel
This was the actual root cause of the "old tab widgets still visible
after switching tabs" bug.
Background: at widget realization time, DashboardLayout.realizeWidget
captures the outer cell handle into widget.hCellPanel, calls
widget.render(contentPanel), and the widget's render method
reassigns widget.hPanel = contentPanel (the INNER content panel,
parented inside the outer cell). After realization:
- widget.hCellPanel = outer cell uipanel (parented to hCanvas)
- widget.hPanel = inner content uipanel (sibling of WidgetButtonBar)
rerenderWidgets did `delete(w.hPanel)` — which deleted ONLY the inner
content panel. The outer cell + its WidgetButtonBar (with the i / ^
buttons) survived as a "ZOMBIE" still parented to the canvas with
Visible='on'.
allocatePanels then created NEW outer cells for the same widgets,
stacking on top. Each subsequent rerender added another zombie.
After several resize + tab-switch cycles the canvas had ~69 children
where ~10 were live and the rest were zombies stacking up at
overlapping z-positions, painting over freshly switched-to pages.
Visible symptom: white widgets with i / ^ buttons in title bars
(zombie's WidgetButtonBar) and blank content area (the legitimate
new content panel sits behind the zombie).
Fix: delete the OUTER cell panel. `hCellPanel` is set at realization
time; before realization `hPanel` IS the outer cell (set by
allocatePanels). After delete cascades to all children, clear both
handles so the widget is in a clean "unrealized" state.
outer = w.hCellPanel;
if isempty(outer) || ~ishandle(outer)
outer = w.hPanel; % pre-realization
end
if ~isempty(outer) && ishandle(outer)
delete(outer);
end
w.hPanel = [];
w.hCellPanel = [];
Verified on the live demo via a canary that counts canvas children
across 4 explicit rerenders + a resize-triggered backstop + a
switchPage(4):
Initial render -> 29 canvas children
After 1 rerender -> 29
After 4 rerenders -> 29
After resize + 1.5 s wait -> 29
After switchPage(4) -> 29
No accumulation. Static analysis clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
⚠️ Performance Alert ⚠️
Possible performance regression was detected for benchmark 'FastSense Performance'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.10.
| Benchmark suite | Current: 13b3d64 | Previous: 31d04b7 | Ratio |
|---|---|---|---|
Downsample mean (1M) |
1.161 ms |
1.05 ms |
1.11 |
Instantiation mean (1M) |
151.639 ms |
122.705 ms |
1.24 |
Instantiation mean std(1M) |
1.447 ms |
0.257 ms |
5.63 |
Render mean (1M) |
241.323 ms |
191.878 ms |
1.26 |
Render mean std(1M) |
2.554 ms |
1.447 ms |
1.77 |
Zoom cycle mean (1M) |
13.799 ms |
11.848 ms |
1.16 |
Zoom cycle mean std(1M) |
3.623 ms |
2.882 ms |
1.26 |
Downsample mean (5M) |
7.523 ms |
6.366 ms |
1.18 |
Instantiation mean (5M) |
170.09 ms |
140.366 ms |
1.21 |
Instantiation mean std(5M) |
0.786 ms |
0.311 ms |
2.53 |
Render mean (5M) |
243.556 ms |
196.187 ms |
1.24 |
Render mean std(5M) |
1.541 ms |
1.008 ms |
1.53 |
Zoom cycle mean (5M) |
14.096 ms |
12.219 ms |
1.15 |
Downsample mean (10M) |
15.16 ms |
12.771 ms |
1.19 |
Downsample mean std10M) |
0.087 ms |
0.024 ms |
3.62 |
Instantiation mean (10M) |
191.808 ms |
161.095 ms |
1.19 |
Render mean (10M) |
251.635 ms |
197.073 ms |
1.28 |
Zoom cycle mean (10M) |
13.837 ms |
11.789 ms |
1.17 |
Downsample mean (50M) |
78.333 ms |
66.063 ms |
1.19 |
Downsample mean std50M) |
0.373 ms |
0.038 ms |
9.82 |
Instantiation mean (50M) |
1243.5 ms |
1124.189 ms |
1.11 |
Render mean (50M) |
248.291 ms |
193.59 ms |
1.28 |
Render mean std50M) |
3.724 ms |
1.742 ms |
2.14 |
Zoom cycle mean (50M) |
13.92 ms |
12.03 ms |
1.16 |
Zoom cycle mean std50M) |
0.715 ms |
0.524 ms |
1.36 |
Downsample mean (100M) |
156.25 ms |
129.216 ms |
1.21 |
Downsample mean ( std00M) |
0.651 ms |
0.109 ms |
5.97 |
Instantiation mean (100M) |
2437.139 ms |
2074.798 ms |
1.17 |
Instantiation mean ( std00M) |
83.583 ms |
36.44 ms |
2.29 |
Render mean (100M) |
248.286 ms |
196.319 ms |
1.26 |
Zoom cycle mean (100M) |
13.955 ms |
12.041 ms |
1.16 |
Zoom cycle mean ( std00M) |
0.813 ms |
0.46 ms |
1.77 |
Downsample mean ( std00M) |
5.846 ms |
0.109 ms |
53.63 |
Instantiation mean ( std00M) |
150.25 ms |
36.44 ms |
4.12 |
Render mean ( std00M) |
3.786 ms |
0.91 ms |
4.16 |
Zoom cycle mean (500M) |
13.956 ms |
10.681 ms |
1.31 |
Zoom cycle mean ( std00M) |
0.859 ms |
0.46 ms |
1.87 |
Dashboard create+render mean |
941.867 ms |
386.682 ms |
2.44 |
Dashboard create+render stdmean |
26.081 ms |
19.469 ms |
1.34 |
Dashboard live tick mean |
164.502 ms |
134.453 ms |
1.22 |
Dashboard page switch mean |
163.273 ms |
131.847 ms |
1.24 |
Dashboard broadcastTimeRange mean |
0.12 ms |
0.075 ms |
1.60 |
Dashboard broadcastTimeRange stdmean |
0.215 ms |
0.024 ms |
8.96 |
This comment was automatically generated by workflow using github-action-benchmark.
CC: @HanSur94
This was referenced May 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three quick-task arcs from 2026-05-13:
260512-hrn-followup(6 commits): Follow toggle onDashboardToolbar+ below-slider edge labels + middle duration label.260513-ovt(4 commits): Live mode strictly appends data; Follow toggle works across every page.260513-q7w(6 commits): Debounced post-resize refresh + serialize switchPage with rerender + zombie-panel root-cause fix.Live mode no longer mutates widget axes silently. Follow toggle tracks the data tail in X across all pages while keeping Y frozen. Resize + tab-switch no longer leaves widgets white.
What changed
Live + Follow (260512-hrn-followup, 260513-ovt)
DashboardToolbar: Follow button now lives on the user-visible top toolbar (was on the per-figureFastSenseToolbaronly).FastSense.snapToTail(): 2% right-edge padding so the live tail doesn't sit flush against the axes frame.FastSense.applyViewMode('follow'): same 2% pad on each live-tick slide.FastSenseWidget.LiveViewModedefault flipped from'reset'→'preserve'. Live ticks no longer auto-grow XLim every tick.FastSenseWidget.refresh/update: removedautoScaleY_(y)— Y stays at the user's view across live ticks (initial Y still set viarebuildForTag_).DashboardEngine.onLiveTick: removed the per-tickbroadcastTimeRangecall. Slider-driven andbroadcastTimeRangeNowpaths still drive widgets when the user actually moves the slider.FastSenseToolbar.syncFollowState: moved into a public methods block soFastSense.onXLimChanged's auto-disengage hook can actually reach it (ismethodreturned false for private methods — Follow button never toggled visually OFF on user pan).DashboardEngine.{allPageWidgets,activePageWidgets}: moved into a public methods block soDashboardToolbar.onFollowTogglecan iterate widgets across every page on multi-page dashboards (MethodRestrictedwas being swallowed by the toolbar's try/catch).TimeRangeSelector: middle duration label always shows fullXd Yh Zm Ws; below-slider edge labels mirror the selection (not the data range).Resize stability (260513-q7w)
DashboardEngine.ResizeDebounceTimer(300 ms) — cheap two-pass refresh: Pass 1update()/refresh()on every active-page widget; Pass 2 detects any FastSenseWidget whose first line has empty XData while its Tag has data, and escalates to per-widget refresh() / engine-level rerenderWidgets() if still white.DashboardEngine.ResizeFinalRedrawTimer(1.2 s) — unconditionalrerenderWidgets()backstop. Both timers restart on every resize event so neither fires during continuous drag.switchPagecancels both timers viacancelResizeTimers_()AND waits up to 3 s for any in-flightrerenderWidgetsto complete before mutating state.IsRerendering_flag (set viaonCleanup) makesonResizeearly-return during rerender cascades.rerenderWidgetsnow deletes the OUTER cell panel (hCellPanel, falling back tohPanelfor pre-realization widgets) — previous code deleted onlyhPanel, which after realization points to the INNER content panel, leaving the outer cell + WidgetButtonBar as "zombies" on the canvas that stacked up over multiple rerenders and painted over freshly switched-to pages.Test plan
test_dashboard_time_sync_all_pages: 5/5 PASStest_dashboard_range_selector_integration: 2/2 PASStest_fastsense_follow_toggle: 10/10 PASSrerenderWidgets()+ resize-triggered backstop +switchPage(4)— zero zombie accumulationXData=[]→isWidgetLineWhite_detects →refreshActivePageWidgetsAfterResize_restores to 941 samples)'follow'; revert to'preserve'on OFF🤖 Generated with Claude Code