diff --git a/+labkit/+ui/runWithBusyState.m b/+labkit/+ui/runWithBusyState.m new file mode 100644 index 0000000..3a9fa25 --- /dev/null +++ b/+labkit/+ui/runWithBusyState.m @@ -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 + + 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 + '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 diff --git a/apps/image_measurement/labkit_FocusStack_app.m b/apps/image_measurement/labkit_FocusStack_app.m index 261689d..2b3ca1b 100644 --- a/apps/image_measurement/labkit_FocusStack_app.m +++ b/apps/image_measurement/labkit_FocusStack_app.m @@ -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) @@ -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.'); diff --git a/docs/architecture.md b/docs/architecture.md index 3f9c3e0..d6aa5f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/ui.md b/docs/ui.md index 681d4f4..a25b109 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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); ``` @@ -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. @@ -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: diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_helpers.m b/tests/suites/labkit/ui/test_gui_layout_ui_helpers.m index fb77c30..5d7e170 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_helpers.m +++ b/tests/suites/labkit/ui/test_gui_layout_ui_helpers.m @@ -20,6 +20,7 @@ function test_gui_layout_ui_helpers() checkCreateWorkbenchHelper(h); checkTopBottomPlotControlsHelper(h); checkTopBottomPlotStateHelpers(h); + checkRunWithBusyStateHelper(); checkFileSelectionPanelHelper(h); end @@ -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 + 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 diff --git a/tests/suites/project/test_package_public_surface.m b/tests/suites/project/test_package_public_surface.m index 068f8aa..16feba2 100644 --- a/tests/suites/project/test_package_public_surface.m +++ b/tests/suites/project/test_package_public_surface.m @@ -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'), ...