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
177 changes: 177 additions & 0 deletions +labkit/+ui/runWithBusyState.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
function varargout = runWithBusyState(fig, workFcn, opts)
%RUNWITHBUSYSTATE Run synchronous GUI work with busy feedback.
%
% Usage:
% labkit.ui.runWithBusyState(fig, @() refreshResults(), opts);
% result = labkit.ui.runWithBusyState(fig, @() computeResult(), opts);
%
% Inputs:
% fig - owning uifigure or figure. Empty or invalid figures are accepted;
% control disabling still runs for valid controls in opts.controls.
% workFcn - scalar function handle to run synchronously.
% opts - optional struct.
%
% Options:
% controls - UI component handle array or cell array. Valid components
% with an Enable property are disabled while workFcn runs and restored
% afterward. Default [].
% title - progress dialog title, default "Working".
% message - progress dialog message, default "Please wait...".
% showDialog - logical, default true. Shows an indeterminate progress
% dialog when fig is valid and uiprogressdlg is available.
% indeterminate - logical, default true. Uses an indeterminate progress
% dialog. When false, opts.value is used as the initial value.
% value - scalar in [0, 1], default 0.05, used only when indeterminate is
% false.
% pointer - figure pointer while work runs, default "watch".
%
% Outputs:
% varargout - outputs returned by workFcn. When no outputs are requested,
% workFcn is called for side effects only.
%
% Notes:
% This helper is intended for long, synchronous callbacks. If the callback
% permanently changes control enable states, refresh those states after
% runWithBusyState returns so the cleanup restore does not preserve stale
% pre-run values.

if nargin < 3
opts = struct();
end
if ~isa(workFcn, 'function_handle')
error('labkit:ui:runWithBusyState:InvalidCallback', ...
'workFcn must be a function handle.');
end

validFig = isLiveHandle(fig);
controlState = disableControls(optionValue(opts, 'controls', []));
[oldPointer, pointerChanged] = setBusyPointer( ...
fig, validFig, optionValue(opts, 'pointer', 'watch'));
dlg = createProgressDialog(fig, validFig, opts);
cleanupObj = onCleanup(@() restoreBusyState( ...
controlState, fig, validFig, oldPointer, pointerChanged, dlg)); %#ok<NASGU>

drawnow;
if nargout == 0
workFcn();
else
[varargout{1:nargout}] = workFcn();
end
end

function value = optionValue(opts, name, defaultValue)
value = defaultValue;
if isstruct(opts) && isfield(opts, name)
value = opts.(name);
end
end

function controlState = disableControls(controls)
handles = normalizeControls(controls);
controlState = struct('handle', {}, 'enable', {});
for k = 1:numel(handles)
h = handles{k};
if ~isLiveHandle(h) || ~isprop(h, 'Enable')
continue;
end
controlState(end+1) = struct( ... %#ok<AGROW>
'handle', h, ...
'enable', h.Enable);
h.Enable = 'off';
end
end

function handles = normalizeControls(controls)
if isempty(controls)
handles = {};
elseif iscell(controls)
handles = controls(:);
else
try
handles = num2cell(controls(:));
catch
handles = {controls};
end
end
end

function [oldPointer, pointerChanged] = setBusyPointer(fig, validFig, pointer)
oldPointer = '';
pointerChanged = false;
if ~validFig || ~isprop(fig, 'Pointer')
return;
end

oldPointer = fig.Pointer;
try
fig.Pointer = char(pointer);
pointerChanged = true;
catch
pointerChanged = false;
end
end

function dlg = createProgressDialog(fig, validFig, opts)
dlg = [];
if ~validFig || ~optionValue(opts, 'showDialog', true)
return;
end

titleText = char(optionValue(opts, 'title', 'Working'));
messageText = char(optionValue(opts, 'message', 'Please wait...'));
indeterminate = logical(optionValue(opts, 'indeterminate', true));
try
if indeterminate
dlg = uiprogressdlg(fig, ...
'Title', titleText, ...
'Message', messageText, ...
'Indeterminate', 'on');
else
dlg = uiprogressdlg(fig, ...
'Title', titleText, ...
'Message', messageText, ...
'Value', optionValue(opts, 'value', 0.05));
end
catch
dlg = [];
end
end

function restoreBusyState(controlState, fig, validFig, oldPointer, pointerChanged, dlg)
if ~isempty(dlg) && isLiveHandle(dlg)
try
close(dlg);
catch
end
end

if validFig && pointerChanged && isLiveHandle(fig) && isprop(fig, 'Pointer')
try
fig.Pointer = oldPointer;
catch
end
end

for k = numel(controlState):-1:1
h = controlState(k).handle;
if isLiveHandle(h) && isprop(h, 'Enable')
try
h.Enable = controlState(k).enable;
catch
end
end
end
drawnow;
end

function tf = isLiveHandle(h)
tf = ~isempty(h);
if ~tf
return;
end
try
tf = all(isvalid(h));
catch
tf = false;
end
end
36 changes: 29 additions & 7 deletions apps/image_measurement/labkit_FocusStack_app.m
Original file line number Diff line number Diff line change
Expand Up @@ -231,19 +231,22 @@ function onRunFocusStack(~, ~)
end

opts = currentFusionOptions();
registerStack = chkRegister.Value;
busyOpts = struct();
busyOpts.title = 'Focus stacking';
busyOpts.message = 'Fusing selected microscope images...';
busyOpts.controls = focusStackBusyControls();
try
imagesForFusion = S.images;
S.registrationLines = {};
if chkRegister.Value
[imagesForFusion, S.registrationLines] = alignFocusStackImages(S.images);
end
S.alignedImages = imagesForFusion;
S.result = computeFocusStack(imagesForFusion, opts);
payload = labkit.ui.runWithBusyState(fig, ...
@() runFocusStackComputation(opts, registerStack), busyOpts);
catch ME
showError('Focus stacking failed', ME.message);
return;
end

S.alignedImages = payload.imagesForFusion;
S.registrationLines = payload.registrationLines;
S.result = payload.result;
addLog(sprintf('Focus stack complete: %d images fused with %s.', ...
S.result.inputCount, S.result.method));
for k = 1:numel(S.registrationLines)
Expand All @@ -260,6 +263,25 @@ function onRunFocusStack(~, ~)
opts.minConfidence = edtMinConfidence.Value;
end

function payload = runFocusStackComputation(opts, registerStack)
imagesForFusion = S.images;
registrationLines = {};
if registerStack
[imagesForFusion, registrationLines] = alignFocusStackImages(S.images);
end

payload = struct();
payload.imagesForFusion = imagesForFusion;
payload.registrationLines = registrationLines;
payload.result = computeFocusStack(imagesForFusion, opts);
end

function controls = focusStackBusyControls()
controls = {btnOpenFolder, btnOpenFiles, lbImages, chkRegister, ...
edtFocusWindow, edtSmoothRadius, edtMinConfidence, btnRun, ...
btnExportFused, btnExportMap, btnExportSummary};
end

function onExportFused(~, ~)
if ~S.result.ok
showError('No fused image', 'Run focus stack before exporting the fused PNG.');
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ GUI launch/layout checks live in source-aligned suites and are enabled with `--g

## Current Package Surface

- `labkit.ui`: `createWorkbench`, tab specs, file-selection panel, log panel, panel grids, row resizing, axes creation/reset, axes popout, image display, anchor curve editing, prepared-X/Y plotting, result tables, plot controls, listbox state, labeled controls, read-only fields, and internal test/debug support for app maintainers.
- `labkit.ui`: `createWorkbench`, tab specs, file-selection panel, log panel, panel grids, row resizing, axes creation/reset, axes popout, image display, anchor curve editing, prepared-X/Y plotting, result tables, plot controls, listbox state, busy-state feedback, labeled controls, read-only fields, and internal test/debug support for app maintainers.
- `labkit.dta`: DTA file discovery, type detection, single/batch/folder loading, pulse detection, item construction behind the facade, parsed table/curve access, session save/load, and session add/remove/select operations.
- `labkit.biosignal`: MAT timetable and delimited table recording loading, channel extraction, time ROI cropping, filtering, ECG/QRS peak detection, event-centered segmentation, template construction, template-residual SNR-style measurements, and group comparisons.

Expand Down
4 changes: 4 additions & 0 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ labkit.ui.createReadOnlyTextPanel(parent, titleText, row, lines, opts);
labkit.ui.createResultTablePanel(parent, titleText, row, columnNames, initialData);
labkit.ui.createLogPanel(parent, row, initialValue);
labkit.ui.createAnchorCurveEditor(ax, imageSize, opts);
labkit.ui.runWithBusyState(fig, workFcn, opts);
labkit.ui.handleAppRequest(appName, args, nout, handlers);
labkit.ui.createAppDebugLog(appName, opts);
```
Expand Down Expand Up @@ -136,6 +137,8 @@ Axes created through `labkit.ui.createAxes`, workbench dual-plot shells, or rese

Use `showImageAxes` for app-neutral image display boilerplate: it draws an image, uses image-style axes limits, hides ticks, enables standard image navigation, and refreshes the axes popout menu onto the image object. Apps still own how image arrays, overlays, masks, and annotations are computed.

Use `runWithBusyState` around long synchronous callbacks that should give immediate feedback and prevent repeat button clicks. The helper sets a busy pointer, optionally shows an indeterminate progress dialog, disables the controls supplied in `opts.controls`, runs the callback, then restores the prior control states even if the callback errors. Apps still own which controls are passed in and should refresh any final enable/disable state after the helper returns when a computation changes available actions.

Use `tabSpec(..., struct('resizeRows', ...))` when a left tab contains several stacked app-defined sections that may need manual height adjustment. When manually placing a component directly into a workbench tab grid, map the logical row through `labkit.ui.layoutRow(parentGrid, row)`. Most app code should use helpers such as `createPanelGrid`, `createResultTablePanel`, `createLogPanel`, and `createAxes`, which apply that mapping for their parent row. `labkit.ui.addRowResizeHandle` remains a lower-level helper for unusual app-local grids that intentionally reserve a physical handle row.

Use `createAnchorCurveEditor` when an app needs DIC-style image anchor editing: double-click blank image space to add or insert anchors, drag anchors to move them, double-click anchors to delete them, switch between curve and straight-line preview, constrain the maximum point count for tools such as two-endpoint scale bars, and optionally install scroll-wheel zoom on the image axes. The helper owns generic interaction and preview graphics only; apps still own mask construction, scale bars, fitting, analysis, and exports.
Expand Down Expand Up @@ -189,6 +192,7 @@ Debug calls use:
- app-neutral image display boilerplate for prepared image arrays
- result table panels
- listbox selection refresh
- busy-state feedback for long synchronous callbacks
- small labeled controls, read-only text surfaces, and domain-neutral state helpers

`labkit.ui.*` should not own:
Expand Down
65 changes: 65 additions & 0 deletions tests/suites/labkit/ui/test_gui_layout_ui_helpers.m
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function test_gui_layout_ui_helpers()
checkCreateWorkbenchHelper(h);
checkTopBottomPlotControlsHelper(h);
checkTopBottomPlotStateHelpers(h);
checkRunWithBusyStateHelper();
checkFileSelectionPanelHelper(h);
end

Expand Down Expand Up @@ -554,3 +555,67 @@ function checkFileSelectionPanelHelper(h)
assert(multiUi.exportButton.Layout.Row == 3, ...
'File-selection panel should place export below remove/clear when remove is enabled.');
end

function checkRunWithBusyStateHelper()
fig = uifigure('Visible', 'off', 'Name', 'labkit_busy_state_probe');
cleaner = onCleanup(@() delete(fig)); %#ok<NASGU>
grid = uigridlayout(fig, [3 1]);
btnRun = uibutton(grid, 'Text', 'Run');
btnExport = uibutton(grid, 'Text', 'Export', 'Enable', 'off');
btnOther = uibutton(grid, 'Text', 'Other');
fig.Pointer = 'arrow';

opts = struct();
opts.showDialog = false;
opts.controls = {btnRun, btnExport};
result = labkit.ui.runWithBusyState(fig, @probeWork, opts);

assert(result == 42, ...
'Busy-state helper should return the work callback output.');
assert(strcmp(btnRun.Enable, 'on'), ...
'Busy-state helper should restore enabled controls.');
assert(strcmp(btnExport.Enable, 'off'), ...
'Busy-state helper should restore controls that started disabled.');
assert(strcmp(btnOther.Enable, 'on'), ...
'Busy-state helper should not touch controls outside opts.controls.');
assert(strcmp(fig.Pointer, 'arrow'), ...
'Busy-state helper should restore the figure pointer.');

assertThrows(@() labkit.ui.runWithBusyState(fig, @failingWork, opts), ...
'labkit:ui:test:BusyFailure', ...
'Busy-state helper should rethrow callback errors.');
assert(strcmp(btnRun.Enable, 'on') && strcmp(btnExport.Enable, 'off'), ...
'Busy-state helper should restore control states after callback errors.');
assert(strcmp(fig.Pointer, 'arrow'), ...
'Busy-state helper should restore the pointer after callback errors.');

function value = probeWork()
assert(strcmp(btnRun.Enable, 'off'), ...
'Busy-state helper should disable active controls during work.');
assert(strcmp(btnExport.Enable, 'off'), ...
'Busy-state helper should keep initially disabled controls off during work.');
assert(strcmp(btnOther.Enable, 'on'), ...
'Busy-state helper should leave unrelated controls enabled during work.');
assert(strcmp(fig.Pointer, 'watch'), ...
'Busy-state helper should set a busy pointer during work.');
value = 42;
end

function failingWork()
assert(strcmp(btnRun.Enable, 'off'), ...
'Busy-state helper should disable controls before failing work runs.');
error('labkit:ui:test:BusyFailure', 'Synthetic busy-state failure.');
end
end

function assertThrows(fn, expectedIdentifier, label)
try
fn();
catch ME
assert(strcmp(ME.identifier, expectedIdentifier), ...
'%s Expected %s but caught %s.', ...
label, expectedIdentifier, ME.identifier);
return;
end
error('%s Expected an error with identifier %s.', label, expectedIdentifier);
end
3 changes: 2 additions & 1 deletion tests/suites/project/test_package_public_surface.m
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ function test_package_public_surface()
'enableAxesPopout.m', 'handleAppRequest.m', 'hardResetAxis.m', ...
'layoutRow.m', 'plotXY.m', 'popoutAxes.m', 'refreshListboxItems.m', ...
'refreshListboxSelection.m', 'setTopBottomPlotSelections.m', ...
'showImageAxes.m', 'swapTopBottomPlotSelections.m', 'tabSpec.m'}, ...
'runWithBusyState.m', 'showImageAxes.m', ...
'swapTopBottomPlotSelections.m', 'tabSpec.m'}, ...
'Public reusable +labkit UI facade');

h.assertTopLevelMFiles(fullfile(root, '+labkit', '+dta'), ...
Expand Down