Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/Dashboard/ChipBarWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ function refresh(obj)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'uicontrol')); catch, end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
end
Expand Down
10 changes: 10 additions & 0 deletions libs/Dashboard/DashboardBuilder.m
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,16 @@ function onMouseUp(obj)
layout = obj.Engine.Layout;
w = obj.Engine.Widgets{widgetIdx};
oldGrid = w.Position;

% Resolve overlap against other widgets — bump to next free
% row when the dropped position collides (same rule as
% DashboardEngine.addWidget).
existingPositions = {};
for k = 1:numel(obj.Engine.Widgets)
if k == widgetIdx, continue; end
existingPositions{end+1} = obj.Engine.Widgets{k}.Position; %#ok<AGROW>
end
newGrid = layout.resolveOverlap(newGrid, existingPositions);
w.Position = newGrid;

% Check if total rows changed (need full relayout for scroll)
Expand Down
121 changes: 91 additions & 30 deletions libs/Dashboard/DashboardEngine.m
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,38 @@ function exportImage(obj, filepath, format)
useExportApp = ~isOctave && exist('exportapp') ~= 0; %#ok<EXIST>
useExportGraphics = ~isOctave && exist('exportgraphics') ~= 0; %#ok<EXIST>

% Both exportgraphics (MATLAB) and print (Octave) only find
% axes DIRECTLY under the figure — they do not recurse into
% uipanels. Widgets live inside uipanels, so insert a hidden
% 1px stub axes when none exists. exportapp handles uipanels
% on its own and does not need the stub.
stubAxes = [];
if ~useExportApp
topLevelChildren = get(obj.hFigure, 'children');
hasTopAxes = false;
for k = 1:numel(topLevelChildren)
if strcmp(get(topLevelChildren(k), 'type'), 'axes')
hasTopAxes = true;
break;
end
end
if ~hasTopAxes
stubAxes = axes('Parent', obj.hFigure, ...
'Units', 'pixels', 'Position', [0 0 1 1], ...
'Visible', 'off', 'HitTest', 'off');
end
end

% Some MATLAB builds (notably R2020b headless) refuse to
% export an invisible figure with the opaque error
% "Specified handle is not valid for export" even when
% exportgraphics/print are used with a stub axes. Temporarily
% flip Visible='on' around the export call and restore it.
origVisible = get(obj.hFigure, 'Visible');
needsVisibilityToggle = ~useExportApp && strcmp(origVisible, 'off');
if needsVisibilityToggle
try set(obj.hFigure, 'Visible', 'on'); catch, end
end
try
if useExportApp
% exportapp signature is exportapp(fig, filename) only
Expand All @@ -460,34 +491,44 @@ function exportImage(obj, filepath, format)
% export of UI-component figures.
exportapp(obj.hFigure, filepath);
elseif useExportGraphics
% MATLAB R2020a-R2023b headless path. exportgraphics
% explicitly supports -nodisplay mode (unlike print).
% ContentType='image' forces raster output (PNG/JPEG).
% Resolution=150 matches the -r150 used by the legacy
% print() path for visual parity.
exportgraphics(obj.hFigure, filepath, ...
'ContentType', 'image', 'Resolution', 150);
else
% Octave path — preserves stub-axes behaviour (Octave's
% print() does not recurse into uipanels).
topLevelChildren = get(obj.hFigure, 'children');
hasTopAxes = false;
for k = 1:numel(topLevelChildren)
if strcmp(get(topLevelChildren(k), 'type'), 'axes')
hasTopAxes = true;
break;
% MATLAB R2020a-R2023b headless path. Three-tier
% fallback: exportgraphics -> print -> getframe.
% R2020b headless CI rejects the first two with
% "Specified handle is not valid for export" on
% uipanel-only figures even with a stub axes; the
% getframe+imwrite path always works when the
% figure has rendered at least once.
wrote = false;
try
exportgraphics(obj.hFigure, filepath, ...
'ContentType', 'image', 'Resolution', 150);
wrote = true;
catch
end
if ~wrote
try
print(obj.hFigure, devFlag, '-r150', filepath);
wrote = true;
catch
end
end
if ~hasTopAxes
stubAxes = axes('Parent', obj.hFigure, ...
'Units', 'pixels', 'Position', [0 0 1 1], ...
'Visible', 'off', 'HitTest', 'off');
if ~wrote
frame = getframe(obj.hFigure);
imwrite(frame.cdata, filepath);
end
else
% Octave path (print) — stub axes already inserted above.
print(obj.hFigure, devFlag, '-r150', filepath);
end
if ~isempty(stubAxes) && ishandle(stubAxes); delete(stubAxes); end
if needsVisibilityToggle
try set(obj.hFigure, 'Visible', origVisible); catch, end
end
catch ME
if ~isempty(stubAxes) && ishandle(stubAxes); delete(stubAxes); end
if needsVisibilityToggle
try set(obj.hFigure, 'Visible', origVisible); catch, end
end
error('DashboardEngine:imageWriteFailed', ...
'Failed to write image ''%s'': %s', filepath, ME.message);
end
Expand Down Expand Up @@ -1031,6 +1072,17 @@ function delete(obj)
end
end

methods (Hidden)
function triggerTimeSlidersChangedForTest(obj)
%TRIGGERTIMESLIDERSCHANGEDFORTEST Test-only hook to invoke the slider
% callback without going through UI events. Exposes the private
% onTimeSlidersChanged() debounce path to tests.
% (Hidden, not the narrower Access = {?matlab.unittest.TestCase},
% so Octave parsing survives — Octave has no matlab.unittest.)
obj.onTimeSlidersChanged();
end
end

methods (Access = private)

function repositionPanels(obj)
Expand Down Expand Up @@ -1060,16 +1112,25 @@ function repositionPanels(obj)
function wireListeners(obj, w)
%WIRELISTENERS Wire sensor data-change listeners to mark widget dirty.
% Called for both single-page and multi-page addWidget paths so
% sensor PostSet events mark widgets dirty regardless of page routing.
if ~isempty(w.Sensor) && isprop(w.Sensor, 'X')
try
addlistener(w.Sensor, 'X', 'PostSet', @(~,~) w.markDirty());
catch
% Octave may not support addlistener on all property types
end
try
addlistener(w.Sensor, 'Y', 'PostSet', @(~,~) w.markDirty());
catch
% sensor data-change events mark widgets dirty regardless of page
% routing. Uses Tag's 'DataChanged' event (fired from updateData);
% falls back to PostSet on X/Y for legacy Sensor-class bindings
% that still expose settable X/Y properties.
if isempty(w.Sensor), return; end
try
addlistener(w.Sensor, 'DataChanged', @(~,~) w.markDirty());
catch
% Legacy fallback: PostSet on X/Y. Won't fire for Dependent
% properties (Tag.X/Y) but kept for Sensor-class bindings.
if isprop(w.Sensor, 'X')
try
addlistener(w.Sensor, 'X', 'PostSet', @(~,~) w.markDirty());
catch
end
try
addlistener(w.Sensor, 'Y', 'PostSet', @(~,~) w.markDirty());
catch
end
end
end
end
Expand Down
17 changes: 17 additions & 0 deletions libs/Dashboard/DashboardWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ function markUnrealized(obj)
end
end

methods (Static, Access = protected)
function clearPanelControls(hPanel)
%CLEARPANELCONTROLS Delete uicontrol children of hPanel at depth 1,
% preserving DashboardLayout-injected buttons (InfoIconButton,
% DetachButton). Used by widget relayout_/refresh_ paths that
% rebuild their own controls on resize or theme change.
if isempty(hPanel) || ~ishandle(hPanel), return; end
protectedTags = {'InfoIconButton', 'DetachButton'};
kids = findobj(hPanel, '-depth', 1, 'Type', 'uicontrol');
for i = 1:numel(kids)
if ~ismember(get(kids(i), 'Tag'), protectedTags)
delete(kids(i));
end
end
end
end

methods
function setTimeRange(~, ~, ~)
% Override in subclasses to respond to global time changes.
Expand Down
4 changes: 2 additions & 2 deletions libs/Dashboard/FastSenseWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,8 @@ function rebuildForTag_(obj)
obj.XVar = s.source.xVar;
obj.YVar = s.source.yVar;
case 'data'
obj.XData = s.source.x;
obj.YData = s.source.y;
obj.XData = s.source.x(:).';
obj.YData = s.source.y(:).';
end
end

Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/GaugeWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function refresh(obj)
if isfield(s, 'description'), obj.Description = s.description; end
obj.Position = [s.position.col, s.position.row, ...
s.position.width, s.position.height];
if isfield(s, 'range'), obj.Range = s.range; end
if isfield(s, 'range'), obj.Range = s.range(:).'; end
if isfield(s, 'units'), obj.Units = s.units; end
if isfield(s, 'style'), obj.Style = s.style; end
if isfield(s, 'source')
Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/IconCardWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ function refresh(obj)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'uicontrol')); catch, end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
end
Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/MultiStatusWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ function refresh(obj)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'uicontrol')); catch, end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
end
Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/NumberWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function refresh(obj)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'uicontrol')); catch, end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
end
Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/SparklineCardWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ function refresh(obj)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'uicontrol')); catch, end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
end
Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/StatusWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ function refresh(obj)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'uicontrol')); catch, end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
end
Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/TableWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ function refresh(obj)
obj.Description = s.description;
end
if isfield(s, 'columnNames')
obj.ColumnNames = s.columnNames;
obj.ColumnNames = reshape(s.columnNames, 1, []);
end
if isfield(s, 'mode')
obj.Mode = s.mode;
Expand Down
2 changes: 1 addition & 1 deletion libs/Dashboard/TextWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function refresh(~)
function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'uicontrol')); catch, end
try DashboardWidget.clearPanelControls(obj.hPanel); catch, end
try delete(findobj(obj.hPanel, '-depth', 1, 'Type', 'axes')); catch, end
obj.render(obj.hPanel);
end
Expand Down
8 changes: 7 additions & 1 deletion libs/EventDetection/LiveEventPipeline.m
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,16 @@
defaults.MaxBackups = 5;
defaults.MaxCallsPerEvent = 1;
defaults.OnEventStart = [];
defaults.Monitors = []; % NV-pair override for MonitorTargets
opts = parseOpts(defaults, varargin);

% Accept MonitorTargets map (containers.Map of key -> MonitorTag).
if isa(monitors, 'containers.Map')
% 'Monitors' NV-pair takes precedence over the first positional
% arg — lets callers pass an empty/legacy sensors map positionally
% while supplying the real monitors by name (Tag-path pattern).
if isa(opts.Monitors, 'containers.Map')
obj.MonitorTargets = opts.Monitors;
elseif isa(monitors, 'containers.Map')
obj.MonitorTargets = monitors;
else
obj.MonitorTargets = containers.Map( ...
Expand Down
18 changes: 18 additions & 0 deletions libs/FastSense/FastSenseDataStore.m
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@
xOut = []; yOut = [];
return;
end
if xMin > xMax
% Inverted range is a caller bug but historically treated
% as an empty result, not a runtime error.
xOut = []; yOut = [];
return;
end
obj.ensureOpen();
if obj.UseSqlite
[xOut, yOut] = obj.getRangeSqlite(xMin, xMax);
Expand Down Expand Up @@ -588,6 +594,18 @@ function delete(obj)
end
end

methods (Hidden)
function ensureOpenForTest(obj)
%ENSUREOPENFORTEST Test-only hook to force-reopen the DB handle.
% Exposes the private ensureOpen() lifecycle helper so WAL-mode
% tests can query journal_mode via mksqlite(DbId, ...) without
% hitting MethodRestricted. Hidden (rather than narrower
% Access = {?matlab.unittest.TestCase}) so Octave parsing
% survives — Octave has no matlab.unittest.
obj.ensureOpen();
end
end

methods (Access = private)
function ensureMonitorsTable_(obj)
%ENSUREMONITORSTABLE_ Defensive schema for the monitors cache.
Expand Down
6 changes: 6 additions & 0 deletions libs/SensorThreshold/SensorTag.m
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ function updateData(obj, X, Y)
obj.X_ = X;
obj.Y_ = Y;
obj.notifyListeners_();
% notify() is MATLAB-only; Octave hasn't implemented it.
% Widget wiring via addlistener falls back to the explicit
% invalidate() path on Octave.
if exist('OCTAVE_VERSION', 'builtin') == 0
notify(obj, 'DataChanged');
end
end
end

Expand Down
3 changes: 3 additions & 0 deletions libs/SensorThreshold/StateTag.m
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ function updateData(obj, X, Y)
obj.X = X;
obj.Y = Y;
obj.notifyListeners_();
if exist('OCTAVE_VERSION', 'builtin') == 0
notify(obj, 'DataChanged');
end
end
end

Expand Down
4 changes: 4 additions & 0 deletions libs/SensorThreshold/Tag.m
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
EventStore = [] % EventStore handle; [] disables event convenience methods
end

events
DataChanged % Fired when underlying (X, Y) data is mutated.
end

methods
function obj = Tag(key, varargin)
%TAG Construct a Tag with required key and optional name-value pairs.
Expand Down
8 changes: 8 additions & 0 deletions tests/suite/MockDashboardWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,13 @@ function refresh(obj)
obj.Position = [s.position.col, s.position.row, ...
s.position.width, s.position.height];
end

function invokeClearPanelControls(hPanel)
%INVOKECLEARPANELCONTROLS Test-visible wrapper around the
% protected DashboardWidget.clearPanelControls helper.
% Subclasses can call protected static methods on the
% parent class; ordinary test code cannot.
DashboardWidget.clearPanelControls(hPanel);
end
end
end
Loading
Loading