Skip to content

Commit 50d464c

Browse files
HanSur94claude
andcommitted
feat(quick-260519-bs4-05): add polling pause/resume toggle button
- New uicontrol pushbutton on the last-refreshed header row labelled "Pause polling"/"Resume polling" (classical figure -> uicontrol family) - Public method setPollingActive(tf) drives the flow; the button's callback delegates to it so click + programmatic paths are identical - Pause stops RefreshTimer_ without deleting it; resume re-starts the same handle (with startRefreshTimer_ fallback if it died) - Resume fires a synchronous one-shot onRefreshTick_ so the table is immediately fresh instead of waiting up to 1 s for the next tick - markTagsDirty is now a no-op while paused: the user's mental model is "polling off -> table is frozen". No coupling to FastSenseCompanion - Header label appends " (paused)" suffix in-place when paused; the preceding HH:MM:SS is preserved so the user sees WHEN polling stopped - 4 new TestTagStatusTableWindow cases cover Running state transitions, paused markTagsDirty no-op, and button-label toggling Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2a24965 commit 50d464c

2 files changed

Lines changed: 258 additions & 5 deletions

File tree

libs/FastSenseCompanion/TagStatusTableWindow.m

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
hSearchLbl_ = [] % "Search:" label
4949
hHeaderLbl_ = [] % "Tags" right-side header label
5050
hLastRefreshLbl_ = [] % "Last refreshed: HH:MM:SS" label (260519-bs4-04 patch)
51+
hPauseBtn_ = [] % "Pause polling"/"Resume polling" uicontrol pushbutton (260519-bs4-05 patch)
52+
PollingActive_ = true % true = RefreshTimer_ running + markTagsDirty live; false = frozen (260519-bs4-05 patch)
5153
hChipsType_ = [] % 1x5 array of uicontrol pushbuttons (Sensor/Monitor/Composite/State/Derived)
5254
hChipsCrit_ = [] % 1x4 array of uicontrol pushbuttons (Low/Medium/High/Safety)
5355
hChipsActivity_ = [] % 1x2 array of uicontrol pushbuttons (Live/Inactive)
@@ -140,17 +142,34 @@ function openWith(obj, registry, theme, companion)
140142
% --- Last-refreshed label (top-left, small muted text). ---
141143
% Style mirrors EventsLogPane.setLastUpdated convention:
142144
% small font, Menlo monospace, PlaceholderTextColor.
145+
% Width reduced to leave room for the Pause/Resume polling
146+
% button on the right edge of the same row (260519-bs4-05).
143147
obj.hLastRefreshLbl_ = uicontrol(obj.hFig_, ...
144148
'Style', 'text', ...
145149
'Units', 'normalized', ...
146-
'Position', [0.01 0.945 0.98 0.04], ...
150+
'Position', [0.01 0.945 0.85 0.04], ...
147151
'String', 'Last refreshed: --:--:--', ...
148152
'HorizontalAlignment', 'left', ...
149153
'BackgroundColor', t.WidgetBackground, ...
150154
'ForegroundColor', t.PlaceholderTextColor, ...
151155
'FontName', 'Menlo', ...
152156
'FontSize', 10);
153157

158+
% --- Pause/Resume polling button (right edge, same row). ---
159+
% Same widget family as the other controls (uicontrol pushbutton)
160+
% so the classical-figure window stays consistent. Initial label
161+
% matches the default PollingActive_=true state ("Pause polling").
162+
% 260519-bs4-05 patch.
163+
obj.hPauseBtn_ = uicontrol(obj.hFig_, ...
164+
'Style', 'pushbutton', ...
165+
'Units', 'normalized', ...
166+
'Position', [0.87 0.945 0.12 0.04], ...
167+
'String', 'Pause polling', ...
168+
'BackgroundColor', t.WidgetBackground, ...
169+
'ForegroundColor', t.ForegroundColor, ...
170+
'FontSize', 10, ...
171+
'Callback', @(~,~) obj.setPollingActive(~obj.PollingActive_));
172+
154173
% --- Search strip ---
155174
obj.hSearchLbl_ = uicontrol(obj.hFig_, ...
156175
'Style', 'text', ...
@@ -244,9 +263,13 @@ function openWith(obj, registry, theme, companion)
244263

245264
function markTagsDirty(obj, keys)
246265
%MARKTAGSDIRTY Refresh only rows for the listed tag keys.
247-
% keys -- cellstr or single char. No-op when ~IsOpen. Whole body
248-
% wrapped in try/catch so a live tick can never crash via this path.
266+
% keys -- cellstr or single char. No-op when ~IsOpen or when
267+
% PollingActive_ is false (paused -> table is frozen, mirroring
268+
% the user's "polling off = nothing moves" mental model;
269+
% 260519-bs4-05 patch). Whole body wrapped in try/catch so a
270+
% live tick can never crash via this path.
249271
if ~obj.IsOpen; return; end
272+
if ~obj.PollingActive_; return; end
250273
if isempty(keys); return; end
251274
if ischar(keys); keys = {keys}; end
252275
if ~iscell(keys); return; end
@@ -304,6 +327,10 @@ function applyTheme(obj, theme)
304327
obj.hLastRefreshLbl_.BackgroundColor = t.WidgetBackground;
305328
obj.hLastRefreshLbl_.ForegroundColor = t.PlaceholderTextColor;
306329
end
330+
if ~isempty(obj.hPauseBtn_) && isvalid(obj.hPauseBtn_)
331+
obj.hPauseBtn_.BackgroundColor = t.WidgetBackground;
332+
obj.hPauseBtn_.ForegroundColor = t.ForegroundColor;
333+
end
307334
% Re-apply chip active/inactive styling -- pulls Accent
308335
% from the freshly-stored theme.
309336
obj.applyChipStyles_();
@@ -317,6 +344,74 @@ function applyTheme(obj, theme)
317344
end
318345
end
319346

347+
function setPollingActive(obj, tf)
348+
%SETPOLLINGACTIVE Pause or resume the window's refresh polling.
349+
% setPollingActive(true) -> starts RefreshTimer_ (if not running),
350+
% fires one immediate synchronous
351+
% onRefreshTick_ so the user sees fresh
352+
% data right away, sets the button
353+
% label to 'Pause polling' and drops
354+
% the '(paused)' suffix from the
355+
% header label.
356+
% setPollingActive(false) -> stops RefreshTimer_ (without deleting
357+
% it -- close() still cleans up via
358+
% stopRefreshTimer_), sets the button
359+
% label to 'Resume polling' and adds
360+
% the '(paused)' suffix to the header
361+
% label so the user sees WHEN the
362+
% polling stopped.
363+
%
364+
% While paused, markTagsDirty() is a no-op: the table is frozen.
365+
% No-op when ~IsOpen. Whole body wrapped in try/catch so a stray
366+
% click cannot crash the window. 260519-bs4-05 patch.
367+
if ~obj.IsOpen; return; end
368+
if ~islogical(tf) || ~isscalar(tf)
369+
error('FastSenseCompanion:tagStatusTableInvalidPollingFlag', ...
370+
'setPollingActive requires a scalar logical argument.');
371+
end
372+
try
373+
obj.PollingActive_ = tf;
374+
if tf
375+
% Resume: restart timer (if it died, recreate via
376+
% startRefreshTimer_; if it just stopped, start() it).
377+
if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_)
378+
try
379+
if strcmp(get(obj.RefreshTimer_, 'Running'), 'off')
380+
start(obj.RefreshTimer_);
381+
end
382+
catch
383+
% If start() fails (e.g. timer in a weird state),
384+
% rebuild it cleanly.
385+
obj.startRefreshTimer_();
386+
end
387+
else
388+
obj.startRefreshTimer_();
389+
end
390+
% Immediate one-shot refresh on resume so the user sees
391+
% freshness right away rather than waiting up to
392+
% RefreshPeriod_ seconds for the timer tick.
393+
obj.onRefreshTick_();
394+
else
395+
% Pause: stop the timer but DO NOT delete it -- we want
396+
% to be able to re-start the same timer on resume.
397+
% Close-path teardown still runs stopRefreshTimer_
398+
% regardless of paused state.
399+
if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_)
400+
try
401+
if strcmp(get(obj.RefreshTimer_, 'Running'), 'on')
402+
stop(obj.RefreshTimer_);
403+
end
404+
catch
405+
% Best-effort; never throw out of a UI click.
406+
end
407+
end
408+
end
409+
obj.refreshPauseUi_();
410+
catch
411+
% UI click handler must never throw.
412+
end
413+
end
414+
320415
function close(obj)
321416
%CLOSE Programmatic close; routes through onCloseRequest_ for parity.
322417
if obj.IsOpen && ~isempty(obj.hFig_) && isvalid(obj.hFig_)
@@ -370,6 +465,26 @@ function tickForTest(obj)
370465
obj.onRefreshTick_();
371466
end
372467

468+
function s = pauseBtnLabelForTest(obj)
469+
%PAUSEBTNLABELFORTEST Test helper: read the Pause/Resume button text.
470+
% Returns '' when the window is detached or the button is invalid.
471+
% 260519-bs4-05 patch.
472+
s = '';
473+
if ~isempty(obj.hPauseBtn_) && isvalid(obj.hPauseBtn_)
474+
s = obj.hPauseBtn_.String;
475+
end
476+
end
477+
478+
function t = refreshTimerForTest(obj)
479+
%REFRESHTIMERFORTEST Test helper: return the underlying RefreshTimer_.
480+
% Returns [] when the window is detached or the timer is invalid.
481+
% 260519-bs4-05 patch.
482+
t = [];
483+
if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_)
484+
t = obj.RefreshTimer_;
485+
end
486+
end
487+
373488
end
374489

375490
methods (Access = private)
@@ -410,6 +525,7 @@ function onCloseRequest_(obj)
410525
obj.hSearchLbl_ = [];
411526
obj.hHeaderLbl_ = [];
412527
obj.hLastRefreshLbl_ = [];
528+
obj.hPauseBtn_ = [];
413529
obj.hChipsType_ = [];
414530
obj.hChipsCrit_ = [];
415531
obj.hChipsActivity_ = [];
@@ -611,7 +727,12 @@ function onActivityChip_(obj, key)
611727
function setLastRefreshedNow_(obj)
612728
%SETLASTREFRESHEDNOW_ Update the "Last refreshed: HH:MM:SS" label to now.
613729
% 24h clock, second precision, local time. No-op when the label
614-
% is invalid (window detached). 260519-bs4-04 patch.
730+
% is invalid (window detached). When paused, appends " (paused)"
731+
% suffix so the user sees the freshness state -- but the timer
732+
% does not tick while paused, so this branch is only reached
733+
% from the synchronous resume path (where the suffix is dropped
734+
% right after by refreshPauseUi_) and from defensive callers.
735+
% 260519-bs4-04 patch; paused-suffix added in 260519-bs4-05.
615736
if isempty(obj.hLastRefreshLbl_) || ~isvalid(obj.hLastRefreshLbl_)
616737
return;
617738
end
@@ -621,7 +742,44 @@ function setLastRefreshedNow_(obj)
621742
% Octave / stripped MATLAB fallback.
622743
ts = datestr(now, 'HH:MM:SS'); %#ok<DATST,TNOW1>
623744
end
624-
obj.hLastRefreshLbl_.String = sprintf('Last refreshed: %s', ts);
745+
if obj.PollingActive_
746+
obj.hLastRefreshLbl_.String = sprintf('Last refreshed: %s', ts);
747+
else
748+
obj.hLastRefreshLbl_.String = sprintf('Last refreshed: %s (paused)', ts);
749+
end
750+
end
751+
752+
function refreshPauseUi_(obj)
753+
%REFRESHPAUSEUI_ Sync the Pause/Resume button label and header suffix.
754+
% Called from setPollingActive after PollingActive_ flips. Does
755+
% NOT update the "Last refreshed" timestamp -- it only rewrites
756+
% the suffix in-place so the previous HH:MM:SS is preserved
757+
% (the user can see WHEN the polling stopped, per the spec).
758+
% 260519-bs4-05 patch.
759+
% --- Button label ---
760+
if ~isempty(obj.hPauseBtn_) && isvalid(obj.hPauseBtn_)
761+
if obj.PollingActive_
762+
obj.hPauseBtn_.String = 'Pause polling';
763+
else
764+
obj.hPauseBtn_.String = 'Resume polling';
765+
end
766+
end
767+
% --- Header label "(paused)" suffix maintenance ---
768+
if isempty(obj.hLastRefreshLbl_) || ~isvalid(obj.hLastRefreshLbl_)
769+
return;
770+
end
771+
cur = obj.hLastRefreshLbl_.String;
772+
if ~ischar(cur)
773+
return;
774+
end
775+
hasSuffix = ~isempty(regexp(cur, '\(paused\)\s*$', 'once'));
776+
if obj.PollingActive_ && hasSuffix
777+
% Drop the trailing " (paused)".
778+
obj.hLastRefreshLbl_.String = regexprep(cur, '\s*\(paused\)\s*$', '');
779+
elseif ~obj.PollingActive_ && ~hasSuffix
780+
% Add the " (paused)" suffix to the existing timestamp.
781+
obj.hLastRefreshLbl_.String = [strtrim(cur), ' (paused)'];
782+
end
625783
end
626784

627785
function startRefreshTimer_(obj)

tests/suite/TestTagStatusTableWindow.m

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,101 @@ function testLastRefreshedLabelUpdatesAfterTick(testCase)
257257
'contain an HH:MM:SS timestamp after a tick, got ''%s'''], lbl));
258258
end
259259

260+
function testSetPollingActive_falseStopsTimer(testCase)
261+
%TESTSETPOLLINGACTIVE_FALSESTOPSTIMER setPollingActive(false) stops RefreshTimer_.
262+
% The timer must move from Running='on' to Running='off' but
263+
% must NOT be deleted (close() still cleans it up). 260519-bs4-05.
264+
registerTwoSensors_();
265+
app = FastSenseCompanion();
266+
testCase.addTeardown(@() safeClose_(app));
267+
testCase.addTeardown(@() TagRegistry.clear());
268+
269+
w = app.openTagStatusTable();
270+
tBefore = w.refreshTimerForTest();
271+
testCase.verifyNotEmpty(tBefore, ...
272+
'testSetPollingActive_falseStopsTimer: timer must exist after open');
273+
testCase.verifyEqual(get(tBefore, 'Running'), 'on', ...
274+
'testSetPollingActive_falseStopsTimer: timer must be running pre-pause');
275+
276+
w.setPollingActive(false);
277+
278+
tAfter = w.refreshTimerForTest();
279+
testCase.verifyNotEmpty(tAfter, ...
280+
'testSetPollingActive_falseStopsTimer: timer must still exist (not deleted) after pause');
281+
testCase.verifyEqual(get(tAfter, 'Running'), 'off', ...
282+
'testSetPollingActive_falseStopsTimer: timer state must be off after pause');
283+
end
284+
285+
function testSetPollingActive_trueRestartsTimer(testCase)
286+
%TESTSETPOLLINGACTIVE_TRUERESTARTSTIMER setPollingActive(true) re-starts the timer.
287+
registerTwoSensors_();
288+
app = FastSenseCompanion();
289+
testCase.addTeardown(@() safeClose_(app));
290+
testCase.addTeardown(@() TagRegistry.clear());
291+
292+
w = app.openTagStatusTable();
293+
w.setPollingActive(false);
294+
t = w.refreshTimerForTest();
295+
testCase.verifyEqual(get(t, 'Running'), 'off', ...
296+
'precondition: timer must be off after pause');
297+
298+
w.setPollingActive(true);
299+
300+
tAfter = w.refreshTimerForTest();
301+
testCase.verifyNotEmpty(tAfter, ...
302+
'testSetPollingActive_trueRestartsTimer: timer must exist after resume');
303+
testCase.verifyEqual(get(tAfter, 'Running'), 'on', ...
304+
'testSetPollingActive_trueRestartsTimer: timer state must be on after resume');
305+
end
306+
307+
function testMarkTagsDirty_noOpWhilePaused(testCase)
308+
%TESTMARKTAGSDIRTY_NOOPWHILEPAUSED markTagsDirty must not mutate Data while paused.
309+
registerTwoSensors_();
310+
app = FastSenseCompanion();
311+
testCase.addTeardown(@() safeClose_(app));
312+
testCase.addTeardown(@() TagRegistry.clear());
313+
314+
w = app.openTagStatusTable();
315+
% Snapshot the table.Data BEFORE pausing.
316+
hFig = w.getFigForTest();
317+
hTbl = findall(hFig, 'Type', 'uitable');
318+
testCase.verifyNotEmpty(hTbl, ...
319+
'precondition: uitable handle must be discoverable');
320+
dataBefore = hTbl.Data;
321+
322+
w.setPollingActive(false);
323+
324+
% While paused, mutate one of the underlying tags and call
325+
% markTagsDirty -- it MUST be inert.
326+
tA = TagRegistry.get('press_a');
327+
tA.updateData([1 2 3 4 5 6], [10 11 12 13 14 99]);
328+
w.markTagsDirty({'press_a'});
329+
330+
dataAfter = hTbl.Data;
331+
testCase.verifyEqual(dataAfter, dataBefore, ...
332+
'testMarkTagsDirty_noOpWhilePaused: table.Data must be unchanged while paused');
333+
end
334+
335+
function testPauseBtnLabelFlips(testCase)
336+
%TESTPAUSEBTNLABELFLIPS Button text toggles via setPollingActive.
337+
registerTwoSensors_();
338+
app = FastSenseCompanion();
339+
testCase.addTeardown(@() safeClose_(app));
340+
testCase.addTeardown(@() TagRegistry.clear());
341+
342+
w = app.openTagStatusTable();
343+
testCase.verifyEqual(w.pauseBtnLabelForTest(), 'Pause polling', ...
344+
'testPauseBtnLabelFlips: initial label must be ''Pause polling''');
345+
346+
w.setPollingActive(false);
347+
testCase.verifyEqual(w.pauseBtnLabelForTest(), 'Resume polling', ...
348+
'testPauseBtnLabelFlips: label must read ''Resume polling'' when paused');
349+
350+
w.setPollingActive(true);
351+
testCase.verifyEqual(w.pauseBtnLabelForTest(), 'Pause polling', ...
352+
'testPauseBtnLabelFlips: label must revert to ''Pause polling'' after resume');
353+
end
354+
260355
function testRefreshTimerStoppedAndDeletedOnClose(testCase)
261356
%TESTREFRESHTIMERSTOPPEDANDDELETEDONCLOSE Window close must stop AND delete its timer.
262357
registerTwoSensors_();

0 commit comments

Comments
 (0)