Skip to content

Dashboard Live/Follow preserve + resize/tab-switch zombie-panel fix#136

Merged
HanSur94 merged 31 commits into
mainfrom
claude/dashboard-live-follow-resize-260513
May 13, 2026
Merged

Dashboard Live/Follow preserve + resize/tab-switch zombie-panel fix#136
HanSur94 merged 31 commits into
mainfrom
claude/dashboard-live-follow-resize-260513

Conversation

@HanSur94
Copy link
Copy Markdown
Owner

Summary

Three quick-task arcs from 2026-05-13:

  • 260512-hrn-followup (6 commits): Follow toggle on DashboardToolbar + 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-figure FastSenseToolbar only).
  • 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.LiveViewMode default flipped from 'reset''preserve'. Live ticks no longer auto-grow XLim every tick.
  • FastSenseWidget.refresh/update: removed autoScaleY_(y) — Y stays at the user's view across live ticks (initial Y still set via rebuildForTag_).
  • DashboardEngine.onLiveTick: removed the per-tick broadcastTimeRange call. Slider-driven and broadcastTimeRangeNow paths still drive widgets when the user actually moves the slider.
  • FastSenseToolbar.syncFollowState: moved into a public methods block so FastSense.onXLimChanged's auto-disengage hook can actually reach it (ismethod returned false for private methods — Follow button never toggled visually OFF on user pan).
  • DashboardEngine.{allPageWidgets,activePageWidgets}: moved into a public methods block so DashboardToolbar.onFollowToggle can iterate widgets across every page on multi-page dashboards (MethodRestricted was being swallowed by the toolbar's try/catch).
  • TimeRangeSelector: middle duration label always shows full Xd 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 1 update()/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) — unconditional rerenderWidgets() backstop. Both timers restart on every resize event so neither fires during continuous drag.
  • switchPage cancels both timers via cancelResizeTimers_() AND waits up to 3 s for any in-flight rerenderWidgets to complete before mutating state.
  • IsRerendering_ flag (set via onCleanup) makes onResize early-return during rerender cascades.
  • Root cause for tab-switch white widgets: rerenderWidgets now deletes the OUTER cell panel (hCellPanel, falling back to hPanel for pre-realization widgets) — previous code deleted only hPanel, 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 PASS
  • test_dashboard_range_selector_integration: 2/2 PASS
  • test_fastsense_follow_toggle: 10/10 PASS
  • Live demo verification: canvas-children-count constant at 29 across 4 explicit rerenderWidgets() + resize-triggered backstop + switchPage(4) — zero zombie accumulation
  • Live demo verification: synthetic white-widget test (forced XData=[]isWidgetLineWhite_ detects → refreshActivePageWidgetsAfterResize_ restores to 941 samples)
  • Live demo verification: Follow ON shifts XLim toward data tail with exact width preservation; 2/2 widgets across pages flip to 'follow'; revert to 'preserve' on OFF

🤖 Generated with Claude Code

HanSur94 and others added 30 commits May 12, 2026 12:33
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>
@HanSur94 HanSur94 merged commit 9f46c92 into main May 13, 2026
1 check passed
@HanSur94 HanSur94 deleted the claude/dashboard-live-follow-resize-260513 branch May 13, 2026 18:06
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 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

HanSur94 added a commit that referenced this pull request May 19, 2026
…ggle) (#144)

FastSenseToolbar gained a Follow uitoggletool in #136 (commit 9f46c92,
between Live and Metadata), bringing the count from 12 to 13. The
TestToolbar assertion + comment hadn't been updated, leaving CI red on
the Q-Z batch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant