From 182d6f11b95d91a559d304f1b266cc2a88a6129b Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 13 May 2026 20:23:16 +0200 Subject: [PATCH 1/7] feat(companion): tile/close-all buttons + figure tracking [260513-s0y-T1] - Add OpenedFigures_ private property (column vector of figure handles the companion opens) plus hTileBtn_ / hCloseAllBtn_ button handles. - Extend the inner toolbar grid from 1x4 to 1x6: Events / Live remain in cols 1-2; new Tile (col 3, 70 px, WidgetBorderColor) and Close all (col 4, 90 px, Accent) buttons; the gear moves from col 4 to col 6. - Add trackOpenedFigure_ / pruneOpenedFigures_ private helpers (dedupe by handle equality, prune dead handles before iteration). - Hook onOpenDashboardRequested_ to capture ed.Engine.hFigure and onOpenAdHocPlotRequested_ to capture hFig from openAdHocPlot so both paths feed OpenedFigures_. - tileOpenedWindows: ceil(sqrt(N)) grid on the companion's monitor with a 24 px screen margin and 8 px per-tile gap; row-major top-down fill; per-figure failures are skipped so the rest still tile. - closeAllOpenedWindows: snapshot OpenedFigures_, call close(h) per handle (honoring each figure's CloseRequestFcn), then re-prune. Implements S0Y-01 (Tile windows) and S0Y-02 (Close all windows). Constructor signature, public properties, and existing method names are unchanged -- pure additive surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/FastSenseCompanion/FastSenseCompanion.m | 195 ++++++++++++++++++- 1 file changed, 187 insertions(+), 8 deletions(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 249db577..2f23df5b 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -91,6 +91,13 @@ OriginalLogRowHeight_ = 360 % captured at construction; restored when at least one pane is Inline EventStore_ = [] % EventStore handle resolved via constructor option or auto-discovery EventViewer_ = [] % CompanionEventViewer handle (single-instance) or [] (Task 13 wires it) + % S0Y-01/02 -- companion-opened figure tracking. + OpenedFigures_ = [] % column vector of figure handles the companion opened + % (dashboards via onOpenDashboardRequested_, + % ad-hoc plots via onOpenAdHocPlotRequested_). + % Pruned of invalid handles before each iteration. + hTileBtn_ = [] % toolbar uibutton: Tile windows + hCloseAllBtn_ = [] % toolbar uibutton: Close all end methods (Access = public) @@ -224,11 +231,15 @@ obj.hToolbarPanel_.Layout.Column = [1 3]; obj.hToolbarPanel_.BorderType = 'none'; obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground; - % Inner 1x4 grid — col 1 = Events viewer button (Task 13); - % col 2 = Live: ON/OFF button; col 3 = flex spacer; - % col 4 = gear button. - hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 4]); - hToolbarGrid.ColumnWidth = {110, 110, '1x', 36}; + % Inner 1x6 grid: + % col 1 = Events viewer button (Task 13) (110) + % col 2 = Live: ON/OFF button (110) + % col 3 = Tile windows (S0Y-01) ( 70) + % col 4 = Close all (S0Y-02) ( 90) + % col 5 = flex spacer ('1x') + % col 6 = Settings gear ( 36) + hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 6]); + hToolbarGrid.ColumnWidth = {110, 110, 70, 90, '1x', 36}; hToolbarGrid.RowHeight = {'1x'}; hToolbarGrid.Padding = [4 0 4 0]; hToolbarGrid.ColumnSpacing = 8; @@ -259,10 +270,34 @@ obj.hLiveBtn_.Tooltip = 'Toggle live refresh of the inspector'; obj.hLiveBtn_.ButtonPushedFcn = @(~,~) obj.toggleLiveMode(); - % Col 4 — Settings gear. + % Col 3 — Tile windows (S0Y-01). + obj.hTileBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hTileBtn_.Layout.Row = 1; + obj.hTileBtn_.Layout.Column = 3; + obj.hTileBtn_.Text = 'Tile'; + obj.hTileBtn_.FontSize = 11; + obj.hTileBtn_.FontWeight = 'bold'; + obj.hTileBtn_.Tooltip = 'Arrange companion-opened windows in a grid'; + obj.hTileBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; + obj.hTileBtn_.FontColor = obj.Theme_.ForegroundColor; + obj.hTileBtn_.ButtonPushedFcn = @(~,~) obj.tileOpenedWindows(); + + % Col 4 — Close all (S0Y-02). Uses Accent color to signal destructive action. + obj.hCloseAllBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hCloseAllBtn_.Layout.Row = 1; + obj.hCloseAllBtn_.Layout.Column = 4; + obj.hCloseAllBtn_.Text = 'Close all'; + obj.hCloseAllBtn_.FontSize = 11; + obj.hCloseAllBtn_.FontWeight = 'bold'; + obj.hCloseAllBtn_.Tooltip = 'Close every window the companion opened'; + obj.hCloseAllBtn_.BackgroundColor = obj.Theme_.Accent; + obj.hCloseAllBtn_.FontColor = obj.Theme_.ForegroundColor; + obj.hCloseAllBtn_.ButtonPushedFcn = @(~,~) obj.closeAllOpenedWindows(); + + % Col 6 — Settings gear. obj.hSettingsBtn_ = uibutton(hToolbarGrid, 'push'); obj.hSettingsBtn_.Layout.Row = 1; - obj.hSettingsBtn_.Layout.Column = 4; + obj.hSettingsBtn_.Layout.Column = 6; obj.hSettingsBtn_.Text = char(9881); % gear glyph obj.hSettingsBtn_.FontSize = 14; obj.hSettingsBtn_.Tooltip = 'Companion settings'; @@ -896,6 +931,116 @@ function openEventViewer(obj) obj.openEventViewer_(); end + function tileOpenedWindows(obj) + %TILEOPENEDWINDOWS Arrange every figure the companion opened in a grid. + % Computes a roughly-square ceil(sqrt(N)) tiling on the monitor the + % companion lives on (or the primary monitor if that can't be determined), + % then sets each tracked figure's Position so the windows do not overlap. + % Figures opened outside the companion are not touched. Closed handles + % are pruned silently. + % + % No-op when no tracked figures exist (logs an info line for feedback). + % + % Errors: surfaced via uialert + log entry; never throws. + try + obj.pruneOpenedFigures_(); + figs = obj.OpenedFigures_; + n = numel(figs); + if n == 0 + obj.addLogEntry('info', 'Tile: no companion-opened windows.'); + return; + end + + % Monitor selection -- use the monitor that contains the companion. + mons = get(groot, 'MonitorPositions'); % rows: [x y w h] + screenRect = mons(1, :); % default = primary + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + cp = obj.hFig_.Position; % [x y w h] + cx = cp(1) + cp(3)/2; cy = cp(2) + cp(4)/2; + for m = 1:size(mons, 1) + r = mons(m, :); + if cx >= r(1) && cx < r(1)+r(3) && cy >= r(2) && cy < r(2)+r(4) + screenRect = r; + break; + end + end + end + + % Reserve a margin so windows aren't flush with screen edges. + margin = 24; + gx = screenRect(1) + margin; + gy = screenRect(2) + margin; + gw = max(200, screenRect(3) - 2*margin); + gh = max(200, screenRect(4) - 2*margin); + + % Roughly-square grid: cols = ceil(sqrt(n)), rows = ceil(n/cols). + cols = ceil(sqrt(n)); + rows = ceil(n / cols); + tileW = floor(gw / cols); + tileH = floor(gh / rows); + + for k = 1:n + % Row-major fill, top-down so window 1 ends up top-left. + rIdx = ceil(k / cols); % 1..rows from top + cIdx = mod(k - 1, cols) + 1; % 1..cols from left + x = gx + (cIdx - 1) * tileW; + % MATLAB screen y grows upward -- flip so row 1 is at the top. + y = gy + (rows - rIdx) * tileH; + try + set(figs(k), 'Position', [x, y, tileW - 8, tileH - 8]); + catch + % Skip individual failures -- keep tiling the rest. + end + end + obj.addLogEntry('info', sprintf('Tiled %d window(s).', n)); + catch err + obj.addLogEntry('error', sprintf('Tile failed: %s', err.message)); + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + uialert(obj.hFig_, ... + sprintf('Failed to tile windows: %s', err.message), ... + 'FastSense Companion', 'Icon', 'error'); + end + end + end + + function closeAllOpenedWindows(obj) + %CLOSEALLOPENEDWINDOWS Close every figure the companion opened, then clear tracking. + % Iterates a SNAPSHOT of OpenedFigures_ and calls close(h) per handle -- + % honoring the figure's CloseRequestFcn (DashboardEngine's stops live + + % deletes the figure; openAdHocPlot's closeFcn_ does the same). Closed + % handles drop out via pruneOpenedFigures_ at the end. + % + % Figures opened outside the companion are not affected -- tracking is + % the only source of truth. + try + obj.pruneOpenedFigures_(); + figs = obj.OpenedFigures_; % snapshot -- close() callbacks may mutate + n = numel(figs); + if n == 0 + obj.addLogEntry('info', 'Close all: no companion-opened windows.'); + return; + end + for k = 1:n + try + if ishandle(figs(k)) + close(figs(k)); + end + catch + % Per-figure failure -- continue with the rest. + end + end + obj.pruneOpenedFigures_(); + obj.addLogEntry('info', sprintf('Closed %d window(s).', n)); + catch err + obj.addLogEntry('error', sprintf('Close all failed: %s', err.message)); + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + uialert(obj.hFig_, ... + sprintf('Failed to close windows: %s', err.message), ... + 'FastSense Companion', 'Icon', 'error'); + end + end + end + function openEventViewer_internalForTest(obj) %OPENEVENTVIEWER_INTERNALFORTEST Test shim: call openEventViewer_ directly. obj.openEventViewer_(); @@ -1221,6 +1366,14 @@ function onOpenDashboardRequested_(obj, ~, ed) obj.resolveInspectorState_(); obj.addLogEntry('info', sprintf('Opened dashboard: %s', ... char(ed.Engine.Name))); + % S0Y-01: track the freshly opened dashboard figure so Tile / Close all see it. + try + if ~isempty(ed.Engine) && isvalid(ed.Engine) && ... + ~isempty(ed.Engine.hFigure) && ishandle(ed.Engine.hFigure) + obj.trackOpenedFigure_(ed.Engine.hFigure); + end + catch + end catch err obj.addLogEntry('error', sprintf('Open dashboard failed: %s', err.message)); uialert(obj.hFig_, err.message, 'FastSense Companion'); @@ -1347,6 +1500,27 @@ function clearEventViewerHandle_(obj) end end + function trackOpenedFigure_(obj, hFig) + %TRACKOPENEDFIGURE_ Append a figure handle to OpenedFigures_ (deduped, valid only). + if isempty(hFig) || ~ishandle(hFig); return; end + % Prune dead handles first; then dedupe by handle equality. + obj.pruneOpenedFigures_(); + for k = 1:numel(obj.OpenedFigures_) + if obj.OpenedFigures_(k) == hFig + return; % already tracked + end + end + obj.OpenedFigures_(end+1, 1) = hFig; + end + + function pruneOpenedFigures_(obj) + %PRUNEOPENEDFIGURES_ Drop closed / deleted handles from the tracking list. + if isempty(obj.OpenedFigures_); return; end + keep = arrayfun(@(h) ishandle(h) && isgraphics(h, 'figure'), ... + obj.OpenedFigures_); + obj.OpenedFigures_ = obj.OpenedFigures_(keep); + end + function onOpenAdHocPlotRequested_(obj, ~, evt) %ONOPENADHOCPLOTREQUESTED_ Listener for OpenAdHocPlotRequested event. % Resolves AdHocPlotEventData.TagKeys to Tag handles via Registry_, @@ -1382,7 +1556,12 @@ function onOpenAdHocPlotRequested_(obj, ~, evt) tags{end+1} = obj.Registry_.get(keys{k}); %#ok end end - [~, skipped] = openAdHocPlot(tags, mode, obj.Theme); + [hFig, skipped] = openAdHocPlot(tags, mode, obj.Theme); + % S0Y-01: track the ad-hoc figure so Tile / Close all see it. + try + obj.trackOpenedFigure_(hFig); + catch + end obj.addLogEntry('info', sprintf( ... 'Opened ad-hoc plot: %d tag(s) [%s]', ... numel(tags), char(mode))); From 2867caa7465c60228201dc3793e7c86fbd36dd54 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 13 May 2026 20:25:59 +0200 Subject: [PATCH 2/7] test(companion): tile/close-all coverage [260513-s0y-T2] Function-style test that mirrors tests/test_companion_filter_tags.m conventions and exercises the new tile + close-all surface end-to-end: - test_tracking_on_dashboard_open_ -- track DashboardEngine.hFigure - test_tracking_dedupes_same_figure_ -- same handle, 3 calls = 1 entry - test_pruning_after_external_close_ -- close(fig) outside, then tile; dead handle drops cleanly - test_tile_geometry_no_overlap_ -- 4 figs, pairwise non-overlap + positive size after tile - test_close_all_clears_tracking_ -- 3 figs all close + list empty - test_outside_figures_not_touched_ -- untracked fig keeps Position AND survives Close all - test_toolbar_buttons_present_ -- findall finds 'Tile' + 'Close all' Octave skipped explicitly (FastSenseCompanion is MATLAB-only). Adds two friend accessors -- getOpenedFiguresForTest_ and trackOpenedFigureForTest_ -- so the test can probe + drive private tracking state without spinning up a real DashboardListPane. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/FastSenseCompanion/FastSenseCompanion.m | 18 ++ tests/test_companion_tile_close_buttons.m | 286 +++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 tests/test_companion_tile_close_buttons.m diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 2f23df5b..3ab3057f 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -1056,6 +1056,24 @@ function openEventViewer_internalForTest(obj) f = obj.hFig_; end + function figs = getOpenedFiguresForTest_(obj) + %GETOPENEDFIGURESFORTEST_ Test helper: return the OpenedFigures_ tracking list. + % Used by test_companion_tile_close_buttons. Returns the raw column + % vector of figure handles (post-prune so callers see only valid + % handles). Do NOT call from production code -- this is a friend + % accessor for the test suite only. + obj.pruneOpenedFigures_(); + figs = obj.OpenedFigures_; + end + + function trackOpenedFigureForTest_(obj, hFig) + %TRACKOPENEDFIGUREFORTEST_ Test helper: drive the private trackOpenedFigure_. + % Lets test_companion_tile_close_buttons feed figure handles into + % OpenedFigures_ without spinning up a real DashboardListPane. Same + % dedupe + prune semantics as the production path. + obj.trackOpenedFigure_(hFig); + end + end methods (Access = private) diff --git a/tests/test_companion_tile_close_buttons.m b/tests/test_companion_tile_close_buttons.m new file mode 100644 index 00000000..6deb0130 --- /dev/null +++ b/tests/test_companion_tile_close_buttons.m @@ -0,0 +1,286 @@ +function test_companion_tile_close_buttons() +%TEST_COMPANION_TILE_CLOSE_BUTTONS Tile + Close-all toolbar buttons for FastSenseCompanion. +% +% Covers S0Y-01 (Tile windows) and S0Y-02 (Close all windows). Uses a +% hidden uifigure (Visible='off' set immediately after construction) and +% classical figures with Visible='off' so the test runner stays quiet. +% +% The test reads private state through the friend accessors +% FastSenseCompanion.getOpenedFiguresForTest_ and feeds figures in via +% trackOpenedFigureForTest_ so we exercise the same code path as the real +% onOpenDashboardRequested_ / onOpenAdHocPlotRequested_ hooks without +% spinning up a full DashboardListPane. +% +% See also FastSenseCompanion, openAdHocPlot, test_companion_filter_tags. + + add_paths_(); + if exist('OCTAVE_VERSION', 'builtin') ~= 0 + fprintf('Octave detected -- FastSenseCompanion is MATLAB-only; skipping.\n'); + return; + end + + nPassed = 0; nTotal = 0; + + [p, t] = test_tracking_on_dashboard_open_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_tracking_dedupes_same_figure_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_pruning_after_external_close_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_tile_geometry_no_overlap_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_close_all_clears_tracking_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_outside_figures_not_touched_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_toolbar_buttons_present_(); nPassed = nPassed + p; nTotal = nTotal + t; + + if nPassed == nTotal + fprintf(' All %d tests passed.\n', nTotal); + else + error('test_companion_tile_close_buttons: %d / %d passed', nPassed, nTotal); + end +end + +% ------------------------------------------------------------------------- +% Sub-tests +% ------------------------------------------------------------------------- + +function [passed, total] = test_tracking_on_dashboard_open_() +%TEST_TRACKING_ON_DASHBOARD_OPEN_ trackOpenedFigure_ appends DashboardEngine figures. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + d = DashboardEngine('S0Y-track-1'); + d.render(); + set(d.hFigure, 'Visible', 'off'); + figureCleanup = onCleanup(@() safe_delete_fig_(d.hFigure)); %#ok + + app.trackOpenedFigureForTest_(d.hFigure); + + figs = app.getOpenedFiguresForTest_(); + assert(numel(figs) == 1, 'expected 1 tracked figure, got %d', numel(figs)); + assert(figs(1) == d.hFigure, 'tracked figure must equal d.hFigure'); + + passed = 1; +end + +function [passed, total] = test_tracking_dedupes_same_figure_() +%TEST_TRACKING_DEDUPES_SAME_FIGURE_ Calling trackOpenedFigure_ twice on the same handle is a no-op. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + d = DashboardEngine('S0Y-dedupe'); + d.render(); + set(d.hFigure, 'Visible', 'off'); + figureCleanup = onCleanup(@() safe_delete_fig_(d.hFigure)); %#ok + + app.trackOpenedFigureForTest_(d.hFigure); + app.trackOpenedFigureForTest_(d.hFigure); % duplicate -- must NOT double-add + app.trackOpenedFigureForTest_(d.hFigure); + + figs = app.getOpenedFiguresForTest_(); + assert(numel(figs) == 1, ... + 'dedupe failed: expected 1 entry, got %d', numel(figs)); + + passed = 1; +end + +function [passed, total] = test_pruning_after_external_close_() +%TEST_PRUNING_AFTER_EXTERNAL_CLOSE_ Closed handles are dropped from tracking. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + % Make two classical figures, track them, close one externally, then + % call tileOpenedWindows and confirm the dead handle is gone. + f1 = figure('Visible', 'off', 'NumberTitle', 'off', 'Name', 'S0Y-prune-1'); + f2 = figure('Visible', 'off', 'NumberTitle', 'off', 'Name', 'S0Y-prune-2'); + cleanupFigs = onCleanup(@() safe_delete_fig_([f1 f2])); %#ok + + app.trackOpenedFigureForTest_(f1); + app.trackOpenedFigureForTest_(f2); + assert(numel(app.getOpenedFiguresForTest_()) == 2, 'pre-close: expected 2 tracked'); + + % Close f1 outside the companion's lifecycle. + close(f1); + + % Tile should not error AND must drop the dead handle. + app.tileOpenedWindows(); + + figs = app.getOpenedFiguresForTest_(); + assert(numel(figs) == 1, ... + 'pruning failed: expected 1 tracked after close(f1), got %d', numel(figs)); + assert(figs(1) == f2, 'remaining tracked handle must be f2'); + + passed = 1; +end + +function [passed, total] = test_tile_geometry_no_overlap_() +%TEST_TILE_GEOMETRY_NO_OVERLAP_ After tile, the 4 figures form a non-overlapping grid. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + figs = gobjects(4, 1); + for k = 1:4 + figs(k) = figure( ... + 'Visible', 'off', ... + 'NumberTitle', 'off', ... + 'Name', sprintf('S0Y-tile-%d', k), ... + 'Position', [100 100 800 600]); + app.trackOpenedFigureForTest_(figs(k)); + end + cleanupFigs = onCleanup(@() safe_delete_fig_(figs)); %#ok + + app.tileOpenedWindows(); + + % Read back positions AFTER tile. + rects = zeros(4, 4); + for k = 1:4 + rects(k, :) = get(figs(k), 'Position'); + end + + % Pairwise non-overlap check. + for i = 1:4 + for j = i+1:4 + assert(~rects_overlap_(rects(i,:), rects(j,:)), ... + 'tile geometry: rect %d and %d overlap [%s] vs [%s]', ... + i, j, mat2str(rects(i,:)), mat2str(rects(j,:))); + end + end + + % Every rect must have positive width and height. + for k = 1:4 + assert(rects(k, 3) > 0 && rects(k, 4) > 0, ... + 'rect %d has non-positive size: %s', k, mat2str(rects(k,:))); + end + + passed = 1; +end + +function [passed, total] = test_close_all_clears_tracking_() +%TEST_CLOSE_ALL_CLEARS_TRACKING_ closeAllOpenedWindows closes every tracked fig + empties list. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + figs = gobjects(3, 1); + for k = 1:3 + figs(k) = figure('Visible', 'off', ... + 'NumberTitle', 'off', ... + 'Name', sprintf('S0Y-closeall-%d', k)); + app.trackOpenedFigureForTest_(figs(k)); + end + cleanupFigs = onCleanup(@() safe_delete_fig_(figs)); %#ok + + app.closeAllOpenedWindows(); + + for k = 1:3 + assert(~ishandle(figs(k)), ... + 'closeAll: figure %d still alive', k); + end + assert(isempty(app.getOpenedFiguresForTest_()), ... + 'closeAll: tracking list not cleared'); + % Companion's own uifigure must still be alive. + assert(isvalid(app), 'companion app handle must survive closeAll'); + + passed = 1; +end + +function [passed, total] = test_outside_figures_not_touched_() +%TEST_OUTSIDE_FIGURES_NOT_TOUCHED_ Figures opened outside the companion are not moved or closed. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + % One TRACKED figure (the companion opens it). + fTracked = figure('Visible', 'off', ... + 'NumberTitle', 'off', 'Name', 'S0Y-tracked', ... + 'Position', [200 200 500 400]); + app.trackOpenedFigureForTest_(fTracked); + + % One OUTSIDE figure (user opened it; companion never sees it). + fOutside = figure('Visible', 'off', ... + 'NumberTitle', 'off', 'Name', 'S0Y-outside', ... + 'Position', [350 350 500 400]); + + cleanupFigs = onCleanup(@() safe_delete_fig_([fTracked fOutside])); %#ok + + origOutsidePos = get(fOutside, 'Position'); + + app.tileOpenedWindows(); + assert(isequal(get(fOutside, 'Position'), origOutsidePos), ... + 'outside figure was moved by tile -- it must not be touched'); + + app.closeAllOpenedWindows(); + assert(~ishandle(fTracked), 'tracked figure must be closed by closeAll'); + assert(ishandle(fOutside), ... + 'outside figure must survive closeAll -- it was not tracked'); + + passed = 1; +end + +function [passed, total] = test_toolbar_buttons_present_() +%TEST_TOOLBAR_BUTTONS_PRESENT_ Two new uibutton handles exist with the expected labels. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + % Probe via findall on the uifigure for buttons whose Text matches. + fig = app.getFigForTest_(); + btns = findall(fig, 'Type', 'uibutton'); + texts = arrayfun(@(b) char(b.Text), btns, 'UniformOutput', false); + + assert(any(strcmp(texts, 'Tile')), ... + 'toolbar: Tile button missing. Found buttons: %s', strjoin(texts, ', ')); + assert(any(strcmp(texts, 'Close all')), ... + 'toolbar: Close all button missing. Found buttons: %s', strjoin(texts, ', ')); + + passed = 1; +end + +% ------------------------------------------------------------------------- +% Helpers +% ------------------------------------------------------------------------- + +function [app, cleanup] = make_app_() +%MAKE_APP_ Build a hidden FastSenseCompanion and return (app, onCleanup). + app = FastSenseCompanion('Dashboards', {}, 'Theme', 'dark'); + % Hide immediately to keep CI quiet -- the constructor turns Visible on + % at the end of construction, so flip it back here. + try + fig = app.getFigForTest_(); + if ~isempty(fig) && isvalid(fig) + fig.Visible = 'off'; + end + catch + end + cleanup = onCleanup(@() safe_close_app_(app)); +end + +function safe_close_app_(app) +%SAFE_CLOSE_APP_ Best-effort companion teardown for onCleanup. + try + if isobject(app) && isvalid(app) + app.close(); + end + catch + end +end + +function safe_delete_fig_(figs) +%SAFE_DELETE_FIG_ Delete any still-valid figure handles. figs may be a vector. + for k = 1:numel(figs) + try + h = figs(k); + if ishandle(h) + delete(h); + end + catch + end + end +end + +function tf = rects_overlap_(a, b) +%RECTS_OVERLAP_ True iff rectangles a and b overlap in 2-D ([x y w h] form). + tf = ~(a(1)+a(3) <= b(1) || b(1)+b(3) <= a(1) || ... + a(2)+a(4) <= b(2) || b(2)+b(4) <= a(2)); +end + +function add_paths_() +%ADD_PATHS_ Make sure libs/ are on path; install if needed. + addpath(fullfile(fileparts(mfilename('fullpath')), '..')); + if isempty(which('FastSenseCompanion')) + install(); + end +end From 1be2cc81165cabf562a2f318fe7bab21c7d4b05f Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 13 May 2026 20:59:19 +0200 Subject: [PATCH 3/7] fix(companion): sync OpenedFigures_ from Engines_ before tile/close-all [260513-s0y-T4] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy tracking hooks in onOpenDashboardRequested_ and onOpenAdHocPlotRequested_ missed two real-world cases: 1. DashboardListPane fires OpenDashboardRequested BEFORE calling engine.render(), so the synchronous listener saw hFigure=[] on first open and skipped. 2. Engines passed into the companion constructor (e.g. demo/industrial_plant/run_demo's pre-rendered dashboard) never go through the event flow at all. Added syncOpenedFigures_ that prunes dead handles and then pulls every Engines_{k}.hFigure that's currently alive into OpenedFigures_. tileOpenedWindows and closeAllOpenedWindows now call sync at the top instead of bare prune, so both buttons work for the demo dashboard and for any dashboard the user clicks in the middle pane. trackOpenedFigure_ stays unchanged — it's still the path for ad-hoc plots (they aren't in Engines_). New 8th sub-test test_sync_pulls_prerendered_engine_ reproduces the live-demo scenario: render a DashboardEngine, pass it into FastSenseCompanion constructor, never fire OpenDashboardRequested, then confirm tile + closeAll find the figure via sync. Test suite: 8/8 pass; TestFastSenseCompanion regression 64/64 pass. --- libs/FastSenseCompanion/FastSenseCompanion.m | 34 ++++++++++++- tests/test_companion_tile_close_buttons.m | 53 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 3ab3057f..94ae3edb 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -943,7 +943,7 @@ function tileOpenedWindows(obj) % % Errors: surfaced via uialert + log entry; never throws. try - obj.pruneOpenedFigures_(); + obj.syncOpenedFigures_(); figs = obj.OpenedFigures_; n = numel(figs); if n == 0 @@ -1013,7 +1013,7 @@ function closeAllOpenedWindows(obj) % Figures opened outside the companion are not affected -- tracking is % the only source of truth. try - obj.pruneOpenedFigures_(); + obj.syncOpenedFigures_(); figs = obj.OpenedFigures_; % snapshot -- close() callbacks may mutate n = numel(figs); if n == 0 @@ -1539,6 +1539,36 @@ function pruneOpenedFigures_(obj) obj.OpenedFigures_ = obj.OpenedFigures_(keep); end + function syncOpenedFigures_(obj) + %SYNCOPENEDFIGURES_ Reconcile OpenedFigures_ with reality before iterating. + % Two reasons we need this before every Tile / Close-all click: + % 1. DashboardListPane fires OpenDashboardRequested BEFORE it calls + % engine.render(), so the synchronous listener sees hFigure=[] + % and can't track on first open. + % 2. Engines passed into the constructor (or attached via setProject) + % may have already been rendered by the caller — they were never + % opened "through" the companion at all. + % Both cases are covered by pulling every Engines_{k}.hFigure that is + % currently alive into OpenedFigures_. Dead handles are pruned first; + % already-tracked handles are skipped (handle-equality dedupe). + obj.pruneOpenedFigures_(); + for k = 1:numel(obj.Engines_) + e = obj.Engines_{k}; + if isempty(e) || ~isvalid(e); continue; end + hFig = e.hFigure; + if isempty(hFig) || ~ishandle(hFig); continue; end + already = false; + for j = 1:numel(obj.OpenedFigures_) + if obj.OpenedFigures_(j) == hFig + already = true; break; + end + end + if ~already + obj.OpenedFigures_(end+1, 1) = hFig; + end + end + end + function onOpenAdHocPlotRequested_(obj, ~, evt) %ONOPENADHOCPLOTREQUESTED_ Listener for OpenAdHocPlotRequested event. % Resolves AdHocPlotEventData.TagKeys to Tag handles via Registry_, diff --git a/tests/test_companion_tile_close_buttons.m b/tests/test_companion_tile_close_buttons.m index 6deb0130..f9765760 100644 --- a/tests/test_companion_tile_close_buttons.m +++ b/tests/test_companion_tile_close_buttons.m @@ -28,6 +28,7 @@ function test_companion_tile_close_buttons() [p, t] = test_close_all_clears_tracking_(); nPassed = nPassed + p; nTotal = nTotal + t; [p, t] = test_outside_figures_not_touched_(); nPassed = nPassed + p; nTotal = nTotal + t; [p, t] = test_toolbar_buttons_present_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_sync_pulls_prerendered_engine_();nPassed = nPassed + p; nTotal = nTotal + t; if nPassed == nTotal fprintf(' All %d tests passed.\n', nTotal); @@ -229,6 +230,58 @@ function test_companion_tile_close_buttons() passed = 1; end +function [passed, total] = test_sync_pulls_prerendered_engine_() +%TEST_SYNC_PULLS_PRERENDERED_ENGINE_ Engines rendered BEFORE construction get tracked. +% Reproduces the live-demo bug: run_demo builds + renders a DashboardEngine, +% THEN passes it to FastSenseCompanion. The OpenDashboardRequested event +% never fires for that engine, so the lazy tracking hook misses it. The +% eager syncOpenedFigures_ helper (called at the top of tileOpenedWindows +% and closeAllOpenedWindows) must pull the figure from Engines_ on first +% click so Tile / Close all work for the demo's initial dashboard. + total = 1; passed = 0; + + % Build + render the engine BEFORE the companion exists. + d = DashboardEngine('S0Y-sync-prerender'); + d.render(); + set(d.hFigure, 'Visible', 'off'); + figCleanup = onCleanup(@() safe_delete_fig_(d.hFigure)); %#ok + + % Construct companion over the already-rendered engine; do not fire + % OpenDashboardRequested ourselves. + app = FastSenseCompanion('Dashboards', {d}, 'Theme', 'dark'); + try + fig = app.getFigForTest_(); + if ~isempty(fig) && isvalid(fig); fig.Visible = 'off'; end + catch + end + appCleanup = onCleanup(@() safe_close_app_(app)); %#ok + + % Before any sync, OpenedFigures_ is empty -- nothing has tracked yet. + pre = app.getOpenedFiguresForTest_(); + assert(isempty(pre), ... + 'expected OpenedFigures_ empty before first sync, got %d entries', numel(pre)); + + % Calling tile triggers syncOpenedFigures_, which pulls d.hFigure from + % Engines_. Read the tracking list right after and verify. + app.tileOpenedWindows(); + post = app.getOpenedFiguresForTest_(); + assert(numel(post) == 1, ... + 'sync must surface 1 engine figure on Tile, got %d', numel(post)); + assert(post(1) == d.hFigure, ... + 'tracked figure must equal the engine''s hFigure'); + + % Snapshot the figure handle BEFORE closeAll -- DashboardEngine's + % CloseRequestFcn (stopLive + delete) may clear d.hFigure, leaving + % d.hFigure == [], and ishandle([]) returns [] which is not convertible + % to a scalar logical for assert(). + hFigSnap = d.hFigure; + app.closeAllOpenedWindows(); + assert(~ishandle(hFigSnap), ... + 'closeAllOpenedWindows must close the pre-rendered engine''s figure'); + + passed = 1; +end + % ------------------------------------------------------------------------- % Helpers % ------------------------------------------------------------------------- From e58bc357894d238970f6c8cb19d2e6b32c056cbe Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 13 May 2026 22:09:45 +0200 Subject: [PATCH 4/7] fix(companion): expose public trackOpenedFigure + hook detail-plot paths [260513-s0y-T5] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more tracking gaps surfaced in live testing after the sync fix: 1. InspectorPane.onOpenDetail_ calls openAdHocPlot DIRECTLY (single-tag "Open detail" button), bypassing OpenAdHocPlotRequested entirely — the returned figure was discarded. 2. CompanionEventViewer.openEventDashboard_ creates an ephemeral DashboardEngine that isn't added to Companion_.Engines_ — syncOpenedFigures_ can't see it. Both paths spawn figures the user expects Tile / Close all to control. Fix: - Added public FastSenseCompanion.trackOpenedFigure(hFig) — thin wrapper over the private trackOpenedFigure_. Preserves dedupe + prune-aware semantics. - InspectorPane.onOpenDetail_ now captures openAdHocPlot's returned hFig and forwards to Orchestrator_.trackOpenedFigure with the same try/catch + ismethod guard used elsewhere. - CompanionEventViewer.openEventDashboard_ does the same with the ephemeral DashboardEngine's hFigure (Companion_ already cached at construction). New 9th sub-test test_public_trackopenedfigure_hook_ exercises the public method directly: tracks → dedupes → closeAll closes. Existing 8 sub-tests still pass. --- .../FastSenseCompanion/CompanionEventViewer.m | 12 ++++++ libs/FastSenseCompanion/FastSenseCompanion.m | 12 ++++++ libs/FastSenseCompanion/InspectorPane.m | 11 +++++- tests/test_companion_tile_close_buttons.m | 39 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 7de21d8a..e80bcaae 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -1262,6 +1262,18 @@ function openEventDashboard_(obj, ev) end obj.bringFigureToFront_(d.hFigure); + + % Register with the companion so the Tile / Close all buttons treat + % the event-detail dashboard like any other companion-opened window. + % The DashboardEngine is ephemeral (not in Companion_.Engines_), so + % syncOpenedFigures_ won't find it on its own. + try + if ~isempty(obj.Companion_) && isvalid(obj.Companion_) && ... + ismethod(obj.Companion_, 'trackOpenedFigure') + obj.Companion_.trackOpenedFigure(d.hFigure); + end + catch + end end function onTableRowSelectionChanged_(obj, evt) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 94ae3edb..83c5635e 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -931,6 +931,18 @@ function openEventViewer(obj) obj.openEventViewer_(); end + function trackOpenedFigure(obj, hFig) + %TRACKOPENEDFIGURE Register a figure the companion opened so Tile / Close all see it. + % Public hook for code paths that spawn figures DIRECTLY (bypassing the + % OpenAdHocPlotRequested event flow) — for example InspectorPane's single-tag + % "Open Detail" handler, which calls openAdHocPlot inline. Pass the returned + % classical figure handle and the companion will dedupe + prune-aware-append + % it to OpenedFigures_. + % + % Silently no-ops on empty / invalid handles. + obj.trackOpenedFigure_(hFig); + end + function tileOpenedWindows(obj) %TILEOPENEDWINDOWS Arrange every figure the companion opened in a grid. % Computes a roughly-square ceil(sqrt(N)) tiling on the monitor the diff --git a/libs/FastSenseCompanion/InspectorPane.m b/libs/FastSenseCompanion/InspectorPane.m index 7e4eb6b6..2957dca8 100644 --- a/libs/FastSenseCompanion/InspectorPane.m +++ b/libs/FastSenseCompanion/InspectorPane.m @@ -849,7 +849,16 @@ function onOpenDetail_(obj, tag) ~isempty(obj.Orchestrator_.Theme) preset = char(obj.Orchestrator_.Theme); end - openAdHocPlot({tag}, 'LinkedGrid', preset); + hFig = openAdHocPlot({tag}, 'LinkedGrid', preset); + % Register with the orchestrator so the companion's Tile / Close all + % buttons treat single-tag detail plots like any other opened window. + try + if ~isempty(obj.Orchestrator_) && isvalid(obj.Orchestrator_) && ... + ismethod(obj.Orchestrator_, 'trackOpenedFigure') + obj.Orchestrator_.trackOpenedFigure(hFig); + end + catch + end obj.log_('info', sprintf('Opened detail plot: %s', char(tag.Key))); catch ME obj.log_('error', sprintf('Open detail failed (%s): %s', char(tag.Key), ME.message)); diff --git a/tests/test_companion_tile_close_buttons.m b/tests/test_companion_tile_close_buttons.m index f9765760..4eebd021 100644 --- a/tests/test_companion_tile_close_buttons.m +++ b/tests/test_companion_tile_close_buttons.m @@ -29,6 +29,7 @@ function test_companion_tile_close_buttons() [p, t] = test_outside_figures_not_touched_(); nPassed = nPassed + p; nTotal = nTotal + t; [p, t] = test_toolbar_buttons_present_(); nPassed = nPassed + p; nTotal = nTotal + t; [p, t] = test_sync_pulls_prerendered_engine_();nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_public_trackopenedfigure_hook_();nPassed = nPassed + p; nTotal = nTotal + t; if nPassed == nTotal fprintf(' All %d tests passed.\n', nTotal); @@ -282,6 +283,44 @@ function test_companion_tile_close_buttons() passed = 1; end +function [passed, total] = test_public_trackopenedfigure_hook_() +%TEST_PUBLIC_TRACKOPENEDFIGURE_HOOK_ Public trackOpenedFigure surfaces a foreign figure. +% Reproduces the live-demo gap reported after the sync fix shipped: a user +% opens a sensor detail plot via the inspector's "Open detail" button, which +% calls openAdHocPlot DIRECTLY (not via the OpenAdHocPlotRequested event). +% The pane now captures the returned hFig and forwards to the orchestrator's +% PUBLIC trackOpenedFigure method. Same hook is used by CompanionEventViewer's +% openEventDashboard_. This test exercises the public hook itself. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + f = figure('Visible', 'off', 'Name', 'S0Y-public-hook'); + figCleanup = onCleanup(@() safe_delete_fig_(f)); %#ok + + % BEFORE the public hook is called -- not tracked. + pre = app.getOpenedFiguresForTest_(); + assert(isempty(pre), 'expected empty pre, got %d', numel(pre)); + + % Public hook -- the same call InspectorPane.onOpenDetail_ and + % CompanionEventViewer.openEventDashboard_ make. + app.trackOpenedFigure(f); + + post = app.getOpenedFiguresForTest_(); + assert(numel(post) == 1, 'expected 1 tracked figure, got %d', numel(post)); + assert(post(1) == f, 'tracked figure must equal the one we passed in'); + + % Dedupe: calling the public hook twice on the same handle still yields 1. + app.trackOpenedFigure(f); + post2 = app.getOpenedFiguresForTest_(); + assert(numel(post2) == 1, 'expected dedupe, got %d', numel(post2)); + + % Close all closes it. + app.closeAllOpenedWindows(); + assert(~ishandle(f), 'closeAllOpenedWindows must close the publicly tracked figure'); + + passed = 1; +end + % ------------------------------------------------------------------------- % Helpers % ------------------------------------------------------------------------- From c47c0c1e82614823b0110c31e9ccf659b366fb4c Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Thu, 14 May 2026 15:09:15 +0200 Subject: [PATCH 5/7] fix(companion): de-maximize + normalize units before set Position in tile [260513-s0y-T6] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardEngine.render creates classical figures with Units='normalized' (Position=[0.05 0.05 0.9 0.9]). The old tile code computed pixel rectangles but did set(fig, 'Position', [x y w h]) directly — MATLAB interpreted those pixels as NORMALIZED fractions of the screen, pushing every figure thousands of screen-widths off-screen. The user reported "Tile button does nothing" because the windows went off-canvas. Also: figures with WindowState='maximized' silently ignore set(Position), so a maximized dashboard wouldn't tile either. Fix (distFig-style): before set(Position), coerce each figure to WindowState='normal' + Units='pixels'. Both wrapped in try/catch so older releases without WindowState still work. Verified on a synthetic 3-dashboard test: BEFORE = all normalized (one maximized), AFTER = all 'normal' + 'pixels' with concrete pixel rectangles laid out in the expected 2x2 grid. --- libs/FastSenseCompanion/FastSenseCompanion.m | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 83c5635e..acc2af04 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -999,7 +999,23 @@ function tileOpenedWindows(obj) % MATLAB screen y grows upward -- flip so row 1 is at the top. y = gy + (rows - rIdx) * tileH; try - set(figs(k), 'Position', [x, y, tileW - 8, tileH - 8]); + % distFig-style robustness: a maximized figure ignores + % set(Position) silently, and normalized units would + % treat our pixel rect as fractions of the screen -- + % both make Tile visually a no-op. Coerce both first. + f = figs(k); + try + if isprop(f, 'WindowState') && ... + ~strcmp(get(f, 'WindowState'), 'normal') + set(f, 'WindowState', 'normal'); + end + catch + end + try + set(f, 'Units', 'pixels'); + catch + end + set(f, 'Position', [x, y, tileW - 8, tileH - 8]); catch % Skip individual failures -- keep tiling the rest. end From db9ef884d8d00a5887792ea4458f9430e55a85a9 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Thu, 14 May 2026 15:31:58 +0200 Subject: [PATCH 6/7] style(test): add whitespace after semicolons in companion test runner mh_style flagged 2 whitespace_semicolon violations at the test dispatch table where the new sync + public-hook sub-tests were added (commits 1be2cc8 and e58bc35). Match the alignment of the existing 7 rows. --- tests/test_companion_tile_close_buttons.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_companion_tile_close_buttons.m b/tests/test_companion_tile_close_buttons.m index 4eebd021..04c3f95e 100644 --- a/tests/test_companion_tile_close_buttons.m +++ b/tests/test_companion_tile_close_buttons.m @@ -28,8 +28,8 @@ function test_companion_tile_close_buttons() [p, t] = test_close_all_clears_tracking_(); nPassed = nPassed + p; nTotal = nTotal + t; [p, t] = test_outside_figures_not_touched_(); nPassed = nPassed + p; nTotal = nTotal + t; [p, t] = test_toolbar_buttons_present_(); nPassed = nPassed + p; nTotal = nTotal + t; - [p, t] = test_sync_pulls_prerendered_engine_();nPassed = nPassed + p; nTotal = nTotal + t; - [p, t] = test_public_trackopenedfigure_hook_();nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_sync_pulls_prerendered_engine_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_public_trackopenedfigure_hook_(); nPassed = nPassed + p; nTotal = nTotal + t; if nPassed == nTotal fprintf(' All %d tests passed.\n', nTotal); From c7035e1ee7f2afe10884a227ba1503ff1e68c8c2 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Thu, 14 May 2026 15:36:42 +0200 Subject: [PATCH 7/7] Merge origin/main into claude/sharp-villani-e7b970 (catch up perf baseline + sfp quick task) --- .planning/STATE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index d45c0d4a..96f9863f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,7 +4,7 @@ milestone: v3.0 milestone_name: FastSense Companion status: shipped last_updated: "2026-05-12T09:20:00.000Z" -last_activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit V/A/L buttons to WidgetButtonBar with backward-compatible default. Verified on live industrial-plant demo. +last_activity: 2026-05-14 -- Quick task 260513-s0y shipped (PR #143): Tile + Close all buttons on FastSenseCompanion top toolbar with 3 tracking fixes (sync from Engines_, public trackOpenedFigure hook, de-maximize + Units=pixels coercion). Verified live on industrial-plant demo. 9/9 sub-tests + 64/64 regression PASS. progress: total_phases: 6 completed_phases: 2 @@ -20,7 +20,7 @@ Phase: 1028 Plan: Not started Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30 Status: Awaiting next milestone (run `/gsd:new-milestone` to scope v3.x or v4.0) -Last activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit V/A/L buttons to WidgetButtonBar with backward-compatible default. Verified on live industrial-plant demo. +Last activity: 2026-05-14 - Quick task 260513-s0y shipped as PR #143 (commits 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88). FastSenseCompanion: Tile + Close all toolbar buttons. Three tracking fixes uncovered by live verification: (1) syncOpenedFigures_ walks Engines_ — DashboardListPane fires OpenDashboardRequested BEFORE engine.render(), and run_demo's pre-rendered demo dashboard never goes through that event flow; (2) public trackOpenedFigure hook on the companion called from InspectorPane.onOpenDetail_ + CompanionEventViewer.openEventDashboard_ which spawn figures directly bypassing the event flow; (3) de-maximize + Units=pixels coercion before set(Position) — DashboardEngine.render defaults to Units=normalized so pixel rectangles got treated as screen fractions, pushing figures off-canvas (root cause of "Tile button does nothing" report). 9/9 sub-tests + 64/64 regression PASS. Verified on live industrial-plant demo. ### Quick Tasks Completed @@ -65,6 +65,7 @@ Last activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit | 260513-ovt | Preserve widget X and Y views across Live ticks + Follow toggle reaches every page — (1) added LiveViewMode='follow' guard inside FastSenseWidget.autoScaleY_, (2) removed `autoScaleY_(y)` from FastSenseWidget.refresh/update, (3) removed `broadcastTimeRange(tStart, tEnd)` from DashboardEngine.onLiveTick, (4) flipped FastSenseWidget.LiveViewMode default 'reset'→'preserve', (5) made FastSenseToolbar.syncFollowState public so FastSense.onXLimChanged's auto-disengage hook actually syncs the Follow button, (6) made DashboardEngine.{allPageWidgets,activePageWidgets} public + onFollowToggle uses allPageWidgets() so Follow actually flips every FastSenseWidget across all pages on multi-page dashboards (was silently no-op via swallowed MethodRestricted). Live mode is now strictly "append data only"; Follow does width-preserving slide with 2% right-edge gap. test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5, test_dashboard_range_selector_integration 2/2; verified end-to-end on industrial plant demo (Follow ON: XLim+0.140d toward tail, width preserved exactly, 2/2 widgets switched; OFF: 2/2 reverted) | 2026-05-13 | 498a5f3, ca5be95, 8d41c48, 63cdff4 | — | [260513-ovt-when-follow-button-is-pressed-y-axis-lim](./quick/260513-ovt-when-follow-button-is-pressed-y-axis-lim/) | | 260513-q7w | Debounced post-resize refresh + ZOMBIE-PANEL fix that stops widgets going white during drag-resize and tab switching — TWO parallel timers on every figure resize event (300 ms cheap two-pass refresh + 1.2 s unconditional rerenderWidgets backstop). switchPage cancels both timers AND waits up to 3 s for in-flight rerenderWidgets to complete before mutating state. `IsRerendering_` flag prevents rerender-cascade scheduling. Re-entrancy guard aborts instead of self-rescheduling. **Root-cause fix**: rerenderWidgets now deletes the OUTER cell panel (via 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 + its WidgetButtonBar chrome alive on the canvas as "zombies" that stacked up over multiple rerenders and painted over freshly switched-to pages. test_dashboard_range_selector_integration 2/2, test_dashboard_time_sync_all_pages 5/5; canvas-children-count canary verifies zero zombie accumulation across 4 rerenders + resize + tab switch (constant 29) | 2026-05-13 | 577bf95, 99c8808, 4eda604, bc305dc, 54d5aa0, 20bcd4c | — | [260513-q7w-during-dashboard-figure-resize-fastsense](./quick/260513-q7w-during-dashboard-figure-resize-fastsense/) | | 260513-sfp | Add auto-y-limit control buttons (V/A/L) to FastSenseWidget WidgetButtonBar — new YLimitMode property (auto-visible / auto-all / locked, default 'auto-visible' reproduces pre-260513-sfp behaviour), setYLimitMode public method (clears UserZoomedY on explicit click so click re-engages autoscale), autoScaleY_ refactored to dispatch on mode AFTER existing precedence guards (YLimits pin / UserZoomedY / FastSense.LiveViewMode=='follow') so 260513-ovt Follow semantics are preserved. DashboardLayout duck-types widget chrome via ismethod(widget,'setYLimitMode'), so future widgets that expose Y-rescale modes opt in without touching DashboardLayout. ASCII glyphs (V/A/L) match existing Info/Detach. reflowChrome_ re-anchors on resize. toStruct omits the default so legacy dashboards stay diff-invisible. test_fastsense_widget_ylimit_modes 11/11, test_fastsense_widget_tag 7/7, test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5. Verified on live industrial-plant demo, all 8 scenarios approved. Known caveat: V/A/L cluster butts against Info button (0-px gap) — inherited from pre-existing addInfoIcon 28-px-typo, explicitly out-of-scope per plan; logged in deferred-items.md | 2026-05-13 | 4db9138, cc18c7f, a9cc181 | Verified | [260513-sfp-add-auto-y-limit-control-buttons-to-fast](./quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/) | +| 260513-s0y | Add Tile + Close all buttons to FastSenseCompanion top toolbar — private OpenedFigures_ tracking + syncOpenedFigures_ (walks Engines_ before tile/close-all) + public trackOpenedFigure hook (InspectorPane.onOpenDetail_ and CompanionEventViewer.openEventDashboard_ forward their figure handles). tileOpenedWindows: ceil(sqrt(N))×ceil(N/cols) grid on monitor containing the companion, 24px margin, 8px gutter, row-major top-down. Before set(Position), coerces each figure to WindowState='normal' + Units='pixels' — root cause of initial "Tile does nothing" report was DashboardEngine.render defaulting to Units='normalized' (pixel rects got treated as screen fractions, pushing figures off-canvas). closeAllOpenedWindows: snapshot + close(h) per handle (honors each figure's CloseRequestFcn). Inner toolbar grid 1×4→1×6 (Events / Live / Tile / Close all / spacer / gear; gear Layout.Column 4→6). 9 sub-tests in test_companion_tile_close_buttons.m PASS; TestFastSenseCompanion regression 64/64 PASS. Verified on live industrial-plant demo. Shipped as PR #143. | 2026-05-14 | 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88 | Shipped (PR #143) | [260513-s0y-add-tile-windows-and-close-all-windows-b](./quick/260513-s0y-add-tile-windows-and-close-all-windows-b/) | ## Progress Bar