diff --git a/libs/Dashboard/MultiStatusWidget.m b/libs/Dashboard/MultiStatusWidget.m index cebb4460..5f174b7e 100644 --- a/libs/Dashboard/MultiStatusWidget.m +++ b/libs/Dashboard/MultiStatusWidget.m @@ -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 - % 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(); @@ -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(); @@ -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). @@ -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 @@ -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 @@ -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 diff --git a/tests/suite/TestMultiStatusWidget.m b/tests/suite/TestMultiStatusWidget.m index a49a6369..5c2b7a0e 100644 --- a/tests/suite/TestMultiStatusWidget.m +++ b/tests/suite/TestMultiStatusWidget.m @@ -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 @@ -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