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
79 changes: 64 additions & 15 deletions libs/Dashboard/MultiStatusWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,18 @@ function render(obj, parentPanel)
% Re-layout on resize so pixel-scaled fonts/geometry stay correct.
try obj.hPanel.SizeChangedFcn = @(~,~) obj.relayout_(); catch, end
theme = obj.getTheme(); %#ok<NASGU>
% DataAspectRatio=[1 1 1] forces equal data units in pixels so
% circles drawn with cos/sin remain circular regardless of how
% the panel resizes. MATLAB letterboxes the axes if needed.
% The axes stretches to fill the panel (no DataAspectRatio):
% forcing DataAspectRatio=[1 1 1] with square [0 1]x[0 1] limits
% letterboxed the drawable area into a square sized by the
% SMALLER panel dimension, so short+wide widgets (e.g. grid
% height 2) clumped every dot/label into a tiny centred square.
% Circularity is preserved instead by scaling rx/ry with the
% panel's pixel aspect ratio in refresh().
obj.hAxes = axes('Parent', parentPanel, ...
'Units', 'normalized', ...
'Position', [0.02 0.02 0.96 0.96], ...
'Visible', 'off', ...
'XLim', [0 1], 'YLim', [0 1], ...
'DataAspectRatio', [1 1 1]);
'XLim', [0 1], 'YLim', [0 1]);
obj.hDots_ = [];
obj.nDotsCached_ = 0;
obj.refresh();
Expand All @@ -49,9 +52,18 @@ function refresh(obj)
n = numel(expandedItems);
if n == 0, return; end

% Pixel size of the drawable axes area — drives the aspect-aware
% auto column count and the rx/ry circularity correction below.
[pxW, pxH] = obj.axesPixelSize_();

cols = obj.Columns;
if isempty(cols)
cols = ceil(sqrt(n));
% Aspect-aware auto layout: pick the column count whose grid
% shape best matches the widget's pixel aspect ratio so cells
% stay roughly square. On a square panel this reduces to the
% historic ceil(sqrt(n)); on a short, wide strip (e.g. grid
% height 2) it yields a single horizontal row.
cols = min(n, max(1, ceil(sqrt(n * pxW / pxH))));
end

theme = obj.getTheme();
Expand Down Expand Up @@ -111,21 +123,34 @@ function refresh(obj)

newDots = gobjects(1, n);

% Equal x/y radii — DataAspectRatio=[1 1 1] on the axes (set in
% render()) keeps the drawn ellipses perfectly circular at any
% panel aspect ratio. No pxW/pxH correction needed.
% Pixel-aware geometry: the axes stretches to fill the panel, so
% visually circular dots need rx/ry scaled by the inverse pixel
% size. The dot radius is 30% of the smaller cell dimension in
% pixels (matching the historic look on square panels), with a
% band reserved below the dot for the label when labels are shown
% so 8 pt text stays readable even at 2-grid-row widget heights.
cellWpx = pxW / cols;
cellHpx = pxH / rows;
labelHpx = 0;
if obj.ShowLabels
labelHpx = min(14, 0.4 * cellHpx); % 8 pt label ~ 11 px tall
end
availHpx = cellHpx - labelHpx;
rPx = 0.3 * min(cellWpx, availHpx);
rx = rPx / pxW;
ry = rPx / pxH;
labelGapY = 2 / pxH; % small fixed gap between dot edge and label top

for i = 1:n
col = mod(i-1, cols);
row = floor((i-1) / cols);

cx = (col + 0.5) / cols;
cy = 1 - (row + 0.5) / rows;
% Dot centred in the cell area above the reserved label band.
cy = 1 - row / rows - (availHpx / 2) / pxH;

item = expandedItems{i};

ry = 0.3 / max(cols, rows);
rx = ry;

if isstruct(item)
% Tag-first dispatch (v2.0 Tag API) — falls through to legacy
% threshold path when .tag is absent (Pitfall 5 preserved).
Expand All @@ -145,8 +170,9 @@ function refresh(obj)
color, 'EdgeColor', 'none');
end
if obj.ShowLabels && isfield(item, 'label')
text(obj.hAxes, cx, cy - ry - 0.02, item.label, ...
text(obj.hAxes, cx, cy - ry - labelGapY, item.label, ...
'HorizontalAlignment', 'center', ...
'VerticalAlignment', 'top', ...
'FontSize', 8, ...
'Color', theme.AxisColor);
end
Expand All @@ -164,8 +190,9 @@ function refresh(obj)
if obj.ShowLabels && ~isempty(item)
name = item.Name;
if isempty(name), name = item.Key; end
text(obj.hAxes, cx, cy - ry - 0.02, name, ...
text(obj.hAxes, cx, cy - ry - labelGapY, name, ...
'HorizontalAlignment', 'center', ...
'VerticalAlignment', 'top', ...
'FontSize', 8, ...
'Color', theme.AxisColor);
end
Expand Down Expand Up @@ -278,6 +305,28 @@ function refresh(obj)
end

methods (Access = private)
function [pxW, pxH] = axesPixelSize_(obj)
%AXESPIXELSIZE_ Pixel size of the drawable axes area inside the panel.
% Measures the axes via a units round-trip (Octave-safe, same
% pattern as ChipBarWidget) so panel title bars and borders are
% accounted for. Guards against degenerate sizes during early
% construction; SizeChangedFcn -> relayout_ re-renders once the
% panel reaches its final geometry, so a transient fallback is fine.
pxW = 100;
pxH = 100;
if isempty(obj.hAxes) || ~ishandle(obj.hAxes), return; end
try
oldUnits = get(obj.hAxes, 'Units');
set(obj.hAxes, 'Units', 'pixels');
pxPos = get(obj.hAxes, 'Position');
set(obj.hAxes, 'Units', oldUnits);
pxW = max(1, pxPos(3));
pxH = max(1, pxPos(4));
catch
% Keep fallback square geometry — matches the historic layout.
end
end

function relayout_(obj)
%RELAYOUT_ Rebuild pixel-scaled elements on panel resize.
if isempty(obj.hPanel) || ~ishandle(obj.hPanel), return; end
Expand Down
103 changes: 103 additions & 0 deletions tests/suite/TestMultiStatusWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,86 @@ function testToStruct(testCase)
testCase.verifyEqual(s.iconStyle, 'square');
end

function testShortWideWidgetSpreadsDotsHorizontally(testCase)
%TESTSHORTWIDEWIDGETSPREADSDOTSHORIZONTALLY Regression for the dot/label
% clump on short, wide widgets (grid Position like [1 1 24 2]): the axes
% used DataAspectRatio=[1 1 1] with square [0 1]x[0 1] limits, which
% letterboxed the drawable area into a square sized by the panel HEIGHT,
% so all dots+labels rendered overlapping in a tiny centred square.
% After the fix the dots must spread across the panel width in a single
% row with pairwise non-overlapping label extents.
hFig = figure('Visible', 'off', 'Position', [100 100 1548 620]);
testCase.addTeardown(@() close(hFig));
% Panel geometry of a full-width, 2-grid-row widget (~90 px tall).
hp = uipanel('Parent', hFig, 'Units', 'pixels', ...
'Position', [24 500 1500 90], 'Title', 'Reticle - sensor status');

w = MultiStatusWidget('Title', 'Reticle - sensor status');
n = 6;
items = cell(1, n);
for k = 1:n
items{k} = struct('threshold', [], 'value', [], ...
'label', sprintf('Sensor%02d', k));
end
w.Sensors = items;
w.render(hp);
drawnow;

rects = testCase.labelExtentsPx_(hp, n);

% 1) No pairwise label overlap (failed with 4 overlapping pairs
% before the fix — labels 46 px wide on a ~25 px pitch).
for a = 1:n-1
for b = a+1:n
testCase.verifyFalse(testCase.rectsOverlap_(rects(a, :), rects(b, :)), ...
sprintf('labels %d and %d overlap at 2-row widget height', a, b));
end
end

% 2) Labels must span most of the panel width (was 49 px = 3%
% of the 1500 px panel before the fix; ~1200 px = 80% after).
centersX = rects(:, 1) + rects(:, 3) / 2;
spread = max(centersX) - min(centersX);
testCase.verifyGreaterThan(spread, 0.5 * 1500, ...
'dot/label strip must spread across the short, wide widget');
end

function testSquarePanelKeepsGridLayout(testCase)
%TESTSQUAREPANELKEEPSGRIDLAYOUT Auto column count on a square panel must
% keep the historic ceil(sqrt(n)) grid (3 cols x 2 rows for 6 items) so
% generously sized widgets do not regress to a single squeezed row.
hFig = figure('Visible', 'off', 'Position', [100 100 600 600]);
testCase.addTeardown(@() close(hFig));
hp = uipanel('Parent', hFig, 'Units', 'pixels', ...
'Position', [50 50 400 400]);

w = MultiStatusWidget('Title', 'Status Grid');
n = 6;
items = cell(1, n);
for k = 1:n
items{k} = struct('threshold', [], 'value', [], ...
'label', sprintf('S%d', k));
end
w.Sensors = items;
w.render(hp);
drawnow;

rects = testCase.labelExtentsPx_(hp, n);
centersX = rects(:, 1) + rects(:, 3) / 2;
centersY = rects(:, 2) + rects(:, 4) / 2;
testCase.verifyNumElements(uniquetol(centersX, 2, 'DataScale', 1), 3, ...
'6 items on a square panel must keep 3 columns');
testCase.verifyNumElements(uniquetol(centersY, 2, 'DataScale', 1), 2, ...
'6 items on a square panel must keep 2 rows');
% Labels stay pairwise non-overlapping at generous sizes too.
for a = 1:n-1
for b = a+1:n
testCase.verifyFalse(testCase.rectsOverlap_(rects(a, :), rects(b, :)), ...
sprintf('labels %d and %d overlap on a square panel', a, b));
end
end
end

function testThresholdOnLimitNotViolated(testCase)
%TESTTHRESHOLDONLIMITNOTVIOLATED Regression for the inclusive (>=) bug in
% deriveColorFromThreshold: a value sitting EXACTLY on a threshold limit
Expand All @@ -41,4 +121,27 @@ function testThresholdOnLimitNotViolated(testCase)
testCase.verifyTrue(isThresholdViolated(lower, 4));
end
end

methods (Access = private)
function rects = labelExtentsPx_(testCase, hp, n)
%LABELEXTENTSPX_ Rendered label Extent rectangles in axes pixel space.
ax = findobj(hp, 'Type', 'axes');
labels = findobj(ax, 'Type', 'text');
testCase.assertNumElements(labels, n, ...
'expected one rendered label per sensor item');
set(labels, 'Units', 'pixels');
rects = zeros(n, 4);
for k = 1:n
rects(k, :) = get(labels(k), 'Extent'); % [x y w h] px
end
end
end

methods (Static, Access = private)
function tf = rectsOverlap_(ra, rb)
%RECTSOVERLAP_ True when two [x y w h] rectangles intersect.
tf = ~(ra(1) + ra(3) <= rb(1) || rb(1) + rb(3) <= ra(1) || ...
ra(2) + ra(4) <= rb(2) || rb(2) + rb(4) <= ra(2));
end
end
end
Loading