diff --git a/+labkit/+ui/+app/create.m b/+labkit/+ui/+app/create.m new file mode 100644 index 0000000..77d7ffb --- /dev/null +++ b/+labkit/+ui/+app/create.m @@ -0,0 +1,54 @@ +function ui = create(spec, varargin) +%CREATE Build a LabKit UI 2.0 workbench from declarative specs. +% +% App-facing contract: +% ui = labkit.ui.app.create(spec, "debug", debugContext) +% +% Inputs: +% spec - scalar app spec from labkit.ui.spec.app. The app spec owns +% controlTabs and workspace specs; all controls use globally unique ids. +% debug - optional labkit.ui.diag debug context. When supplied, the created +% figure is instrumented and the first logPanel mirrors trace lines. +% +% Output: +% ui - registry struct with figure/fig, shell handles, controls, sections, +% tabs, workspace, original spec, and optional debug context. Stable app +% code should use semantic ids and named labkit.ui.view helpers rather +% than adapter internals. + + opts = parseOptions(varargin); + validateAppSpec(spec); + + debug = optionValue(opts, 'debug', []); + ui = buildShellFromSpec(spec, debug); + ui = buildControlTabs(ui, spec.props.controlTabs, debug); + ui = buildWorkspace(ui, spec.props.workspace, debug); + + if isDebugEnabled(debug) && isfield(debug, 'instrumentFigure') + debug.instrumentFigure(ui.figure); + end + setappdata(ui.figure, 'labkitUiRegistry', ui); +end + +function opts = parseOptions(args) + if mod(numel(args), 2) ~= 0 + error('labkit:ui:app:InvalidOptions', ... + 'labkit.ui.app.create options must be name/value pairs.'); + end + opts = struct(); + for k = 1:2:numel(args) + opts.(char(string(args{k}))) = args{k + 1}; + end +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end + +function tf = isDebugEnabled(debugContext) + tf = isstruct(debugContext) && isfield(debugContext, 'enabled') && ... + logical(debugContext.enabled); +end diff --git a/+labkit/+ui/+app/createShell.m b/+labkit/+ui/+app/createShell.m deleted file mode 100644 index 3257a65..0000000 --- a/+labkit/+ui/+app/createShell.m +++ /dev/null @@ -1,72 +0,0 @@ -function ui = createShell(spec) -%CREATESHELL Create the standard LabKit app shell from a named spec. -% -% Usage: -% ui = labkit.ui.app.createShell(struct( ... -% 'title', "Example", ... -% 'position', [90 70 1200 800], ... -% 'leftWidth', 360, ... -% 'options', struct('rightGridSize', [1 1]))); -% -% Inputs: -% spec - scalar struct with fields: -% title - figure title text. -% position - MATLAB figure position [x y width height]. -% leftWidth - initial left controls width in pixels. -% options - optional shell options: -% rightGridSize - custom right grid size, default [1 1]. -% rightRowHeight - custom right grid rows, default {'1x'}. -% rightRowSpacing - scalar right-grid spacing, default 8. -% rightTitle - right panel label. -% tabs - labkit.ui.app.tab struct array. -% -% Output: -% ui - struct of figure, layout, tab grids, and right-side handles. -% -% Apps own controls and workflow inside the returned grids. The shell owns -% split-pane layout, tab construction, scrollable tab grids, resizable rows, -% and standard right-pane plumbing. - - if nargin < 1 || ~isstruct(spec) || ~isscalar(spec) - error('labkit:ui:InvalidAppShellSpec', ... - 'createShell requires a scalar struct spec.'); - end - required = {'title', 'position', 'leftWidth'}; - for k = 1:numel(required) - if ~isfield(spec, required{k}) - error('labkit:ui:InvalidAppShellSpec', ... - 'App shell spec is missing "%s".', required{k}); - end - end - - opts = optionValue(spec, 'options', struct()); - - rightGridSize = optionValue(opts, 'rightGridSize', [1 1]); - rightRowHeight = optionValue(opts, 'rightRowHeight', {'1x'}); - rightRowSpacing = optionValue(opts, 'rightRowSpacing', 8); - - shellLabels = struct( ... - 'controlsPanel', 'Controls', ... - 'rightPanel', optionValue(opts, 'rightTitle', 'Plots')); - tabSpecs = optionValue(opts, 'tabs', standardTabs()); - - ui = createTabbedWorkbenchShell( ... - spec.title, spec.position, spec.leftWidth, shellLabels, tabSpecs, ... - rightGridSize, rightRowHeight, rightRowSpacing); -end - -function tabs = standardTabs() - tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [3 1], ... - {260, 'fit', 'fit'}), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {'fit', '1x'}), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - end -end diff --git a/+labkit/+ui/+app/private/attachColumnResize.m b/+labkit/+ui/+app/private/attachColumnResize.m index af20fe5..6ecd3b6 100644 --- a/+labkit/+ui/+app/private/attachColumnResize.m +++ b/+labkit/+ui/+app/private/attachColumnResize.m @@ -20,8 +20,8 @@ function attachColumnResize(fig, grid, leftColumn, separatorColumn, opts) % WindowButtonMotionFcn/WindowButtonUpFcn callbacks while dragging. % % Notes: -% This helper mutates layout handles only; apps should normally request -% resizable shells through labkit.ui.app.createShell. +% This helper mutates layout handles only; apps request resizable workbench +% layouts through labkit.ui.app.create. if nargin < 5 opts = struct(); diff --git a/+labkit/+ui/+app/private/buildControl.m b/+labkit/+ui/+app/private/buildControl.m new file mode 100644 index 0000000..bedc3ee --- /dev/null +++ b/+labkit/+ui/+app/private/buildControl.m @@ -0,0 +1,654 @@ +% Private UI app helper. Expected caller: buildSection or buildWorkspace. +% Inputs are the current UI registry, one validated control spec, a parent +% grid, target row, and debug context. Output is the updated registry after +% the requested control is built. +function ui = buildControl(ui, controlSpec, parentGrid, row, debug) + switch controlSpec.kind + case 'field' + ui = buildField(ui, controlSpec, parentGrid, row); + case 'rangeField' + ui = buildRangeField(ui, controlSpec, parentGrid, row); + case 'action' + ui = buildAction(ui, controlSpec, parentGrid, row, [1 2]); + case 'actionGroup' + ui = buildActionGroup(ui, controlSpec, parentGrid, row); + case 'pathPanel' + ui = buildPathPanel(ui, controlSpec, parentGrid, row); + case 'resultTable' + ui = buildResultTable(ui, controlSpec, parentGrid, row); + case 'logPanel' + ui = buildLogPanel(ui, controlSpec, parentGrid, row, debug); + case 'statusPanel' + ui = buildStatusPanel(ui, controlSpec, parentGrid, row); + case 'custom' + ui = buildCustom(ui, controlSpec, parentGrid, row, debug); + otherwise + error('labkit:ui:app:UnsupportedControl', ... + 'Unsupported UI 2.0 control kind "%s".', controlSpec.kind); + end +end + +function ui = buildField(ui, fieldSpec, parentGrid, row) + props = fieldSpec.props; + kind = lower(char(string(props.kind))); + labelText = optionValue(props, 'label', fieldSpec.id); + enabled = optionValue(props, 'enabled', true); + + if strcmp(kind, 'checkbox') + control = uicheckbox(parentGrid, 'Text', labelText, ... + 'Enable', onOff(enabled)); + control.Layout.Row = row; + control.Layout.Column = [1 2]; + if isfield(props, 'value') + control.Value = logical(props.value); + end + adapter = registerValueControl(fieldSpec, control, control, []); + appCallback = optionValue(props, 'onChange', []); + control.ValueChangedFcn = semanticValueCallback(fieldSpec.id, appCallback); + setOriginalCallbackName(control, appCallback); + ui.controls.(fieldSpec.id) = adapter; + return; + end + + label = uilabel(parentGrid, 'Text', labelText, ... + 'HorizontalAlignment', 'right'); + label.Layout.Row = row; + label.Layout.Column = 1; + control = createFieldControl(parentGrid, kind, props, enabled); + control.Layout.Row = row; + control.Layout.Column = 2; + adapter = registerValueControl(fieldSpec, control, control, label); + ui.controls.(fieldSpec.id) = adapter; + if isprop(control, 'ValueChangedFcn') + control.ValueChangedFcn = semanticValueCallback(fieldSpec.id, ... + optionValue(props, 'onChange', [])); + end +end + +function control = createFieldControl(parentGrid, kind, props, enabled) + switch kind + case 'text' + control = uieditfield(parentGrid, 'text', 'Enable', onOff(enabled)); + case 'number' + control = uieditfield(parentGrid, 'numeric', 'Enable', onOff(enabled)); + case 'spinner' + control = uispinner(parentGrid, 'Enable', onOff(enabled)); + case 'dropdown' + control = uidropdown(parentGrid, 'Enable', onOff(enabled)); + if isfield(props, 'items') + control.Items = cellstr(string(props.items)); + end + case 'slider' + control = uislider(parentGrid, 'Enable', onOff(enabled)); + case 'readonly' + control = uieditfield(parentGrid, 'text', ... + 'Editable', 'off', 'Enable', onOff(enabled)); + otherwise + error('labkit:ui:app:UnsupportedFieldKind', ... + 'Unsupported UI 2.0 field kind "%s".', kind); + end + applyCommonValueProps(control, props); +end + +function ui = buildRangeField(ui, rangeSpec, parentGrid, row) + props = rangeSpec.props; + labelText = optionValue(props, 'label', rangeSpec.id); + value = optionValue(props, 'value', [0 0]); + if numel(value) ~= 2 + error('labkit:ui:app:InvalidRangeValue', ... + 'rangeField "%s" value must have two elements.', rangeSpec.id); + end + + label = uilabel(parentGrid, 'Text', labelText, ... + 'HorizontalAlignment', 'right'); + label.Layout.Row = row; + label.Layout.Column = 1; + grid = uigridlayout(parentGrid, [1 2]); + grid.Padding = [0 0 0 0]; + grid.ColumnWidth = {'1x', '1x'}; + grid.Layout.Row = row; + grid.Layout.Column = 2; + first = uieditfield(grid, 'numeric'); + first.Layout.Row = 1; + first.Layout.Column = 1; + second = uieditfield(grid, 'numeric'); + second.Layout.Row = 1; + second.Layout.Column = 2; + first.Value = value(1); + second.Value = value(2); + if isfield(props, 'limits') + first.Limits = props.limits; + second.Limits = props.limits; + end + + adapter = baseAdapter(rangeSpec, 'rangeField'); + adapter.label = label; + adapter.grid = grid; + adapter.startHandle = first; + adapter.endHandle = second; + adapter.getValue = @() [first.Value second.Value]; + adapter.setValue = @setRangeValue; + ui.controls.(rangeSpec.id) = adapter; + callback = semanticValueCallback(rangeSpec.id, optionValue(props, 'onChange', [])); + first.ValueChangedFcn = callback; + second.ValueChangedFcn = callback; + + function setRangeValue(newValue) + if numel(newValue) ~= 2 + error('labkit:ui:view:InvalidRangeValue', ... + 'rangeField "%s" value must have two elements.', rangeSpec.id); + end + first.Value = newValue(1); + second.Value = newValue(2); + end +end + +function ui = buildActionGroup(ui, groupSpec, parentGrid, row) + actions = groupSpec.children; + count = max(1, numel(actions)); + grid = uigridlayout(parentGrid, [1 count]); + grid.Padding = [0 0 0 0]; + grid.ColumnSpacing = 8; + grid.ColumnWidth = repmat({'1x'}, 1, count); + grid.Layout.Row = row; + grid.Layout.Column = [1 2]; + adapter = baseAdapter(groupSpec, 'actionGroup'); + adapter.grid = grid; + adapter.actions = struct(); + ui.controls.(groupSpec.id) = adapter; + for k = 1:numel(actions) + [ui, actionAdapter] = buildAction(ui, actions{k}, grid, 1, k); + ui.controls.(groupSpec.id).actions.(actions{k}.id) = actionAdapter; + end +end + +function [ui, adapter] = buildAction(ui, actionSpec, parentGrid, row, column) + props = actionSpec.props; + button = uibutton(parentGrid, 'Text', optionValue(props, 'label', actionSpec.id), ... + 'Enable', onOff(optionValue(props, 'enabled', true))); + button.Layout.Row = row; + button.Layout.Column = column; + adapter = baseAdapter(actionSpec, 'action'); + adapter.button = button; + adapter.handle = button; + adapter.valueHandle = button; + ui.controls.(actionSpec.id) = adapter; + appCallback = optionValue(props, 'onInvoke', []); + button.ButtonPushedFcn = semanticActionCallback(actionSpec.id, appCallback); + setOriginalCallbackName(button, appCallback); +end + +function ui = buildPathPanel(ui, pathSpec, parentGrid, row) + props = pathSpec.props; + panel = uipanel(parentGrid, 'Title', optionValue(props, 'label', pathSpec.id)); + panel.Layout.Row = row; + panel.Layout.Column = [1 2]; + grid = uigridlayout(panel, [3 2]); + grid.RowHeight = {'fit', '1x', 'fit'}; + grid.ColumnWidth = {'1x', '1x'}; + grid.Padding = [8 8 8 8]; + + chooseButton = uibutton(grid, 'Text', chooseButtonText(props), ... + 'ButtonPushedFcn', semanticPathChooseCallback(pathSpec.id, ... + optionValue(props, 'onChoose', []))); + setOriginalCallbackName(chooseButton, optionValue(props, 'onChoose', [])); + chooseButton.Layout.Row = 1; + chooseButton.Layout.Column = 1; + clearButton = uibutton(grid, 'Text', optionValue(props, 'clearLabel', 'Clear'), ... + 'ButtonPushedFcn', semanticPathClearCallback(pathSpec.id, ... + optionValue(props, 'onClear', []))); + setOriginalCallbackName(clearButton, optionValue(props, 'onClear', [])); + clearButton.Layout.Row = 1; + clearButton.Layout.Column = 2; + listbox = uilistbox(grid, 'Items', {char(string(optionValue(props, ... + 'emptyText', 'No selection')))}, ... + 'Multiselect', pathMultiselect(props)); + listbox.ValueChangedFcn = semanticPathSelectionCallback(pathSpec.id, ... + optionValue(props, 'onSelectionChange', [])); + setOriginalCallbackName(listbox, optionValue(props, 'onSelectionChange', [])); + listbox.Layout.Row = 2; + listbox.Layout.Column = [1 2]; + status = uieditfield(grid, 'text', 'Editable', 'off', ... + 'Value', optionValue(props, 'status', 'No selection')); + status.Layout.Row = 3; + status.Layout.Column = [1 2]; + + adapter = baseAdapter(pathSpec, 'pathPanel'); + adapter.panel = panel; + adapter.grid = grid; + adapter.chooseButton = chooseButton; + adapter.clearButton = clearButton; + adapter.listbox = listbox; + adapter.status = status; + adapter.valueHandle = listbox; + adapter.getValue = @() currentPathValues(adapter); + adapter.setValue = @(paths) applyPathSelection(adapter, paths, true); + ui.controls.(pathSpec.id) = adapter; +end + +function ui = buildResultTable(ui, tableSpec, parentGrid, row) + props = tableSpec.props; + panel = uipanel(parentGrid, 'Title', optionValue(props, 'title', tableSpec.id)); + panel.Layout.Row = row; + panel.Layout.Column = [1 2]; + grid = uigridlayout(panel, [1 1]); + grid.Padding = [8 8 8 8]; + columns = optionValue(props, 'columns', {}); + table = uitable(grid, 'ColumnName', columns, ... + 'Data', optionValue(props, 'data', cell(0, numel(columns)))); + table.Layout.Row = 1; + table.Layout.Column = 1; + adapter = baseAdapter(tableSpec, 'resultTable'); + adapter.panel = panel; + adapter.grid = grid; + adapter.table = table; + adapter.valueHandle = table; + ui.controls.(tableSpec.id) = adapter; +end + +function ui = buildLogPanel(ui, logSpec, parentGrid, row, debug) + props = logSpec.props; + panel = uipanel(parentGrid, 'Title', optionValue(props, 'title', logSpec.id)); + panel.Layout.Row = row; + panel.Layout.Column = [1 2]; + grid = uigridlayout(panel, [1 1]); + grid.Padding = [8 8 8 8]; + textArea = uitextarea(grid, 'Editable', 'off', ... + 'Value', textLines(optionValue(props, 'value', {'Ready.'}))); + textArea.Layout.Row = 1; + textArea.Layout.Column = 1; + adapter = baseAdapter(logSpec, 'logPanel'); + adapter.panel = panel; + adapter.grid = grid; + adapter.textArea = textArea; + adapter.valueHandle = textArea; + ui.controls.(logSpec.id) = adapter; + if isDebugEnabled(debug) + debug.attachTextLog(textArea); + end +end + +function ui = buildStatusPanel(ui, statusSpec, parentGrid, row) + props = statusSpec.props; + panel = uipanel(parentGrid, 'Title', optionValue(props, 'title', statusSpec.id)); + panel.Layout.Row = row; + panel.Layout.Column = [1 2]; + grid = uigridlayout(panel, [1 1]); + grid.Padding = [8 8 8 8]; + textArea = uitextarea(grid, 'Editable', 'off', ... + 'Value', textLines(optionValue(props, 'value', {''}))); + textArea.Layout.Row = 1; + textArea.Layout.Column = 1; + adapter = baseAdapter(statusSpec, 'statusPanel'); + adapter.panel = panel; + adapter.grid = grid; + adapter.textArea = textArea; + adapter.valueHandle = textArea; + ui.controls.(statusSpec.id) = adapter; +end + +function ui = buildCustom(ui, customSpec, parentGrid, row, debug) + props = customSpec.props; + panel = uipanel(parentGrid, 'Title', customSpec.id); + panel.Layout.Row = row; + panel.Layout.Column = [1 2]; + context = struct('ui', ui, 'debug', debug, 'spec', customSpec); + handle = props.builder(panel, customSpec.id, context, props); + adapter = baseAdapter(customSpec, 'custom'); + adapter.panel = panel; + adapter.handle = handle; + ui.controls.(customSpec.id) = adapter; +end + +function callback = semanticValueCallback(id, appCallback) + if isempty(appCallback) + callback = []; + return; + end + callback = @wrapped; + + function wrapped(source, rawEvent) + ui = currentUiRegistry(source); + control = ui.controls.(id); + event = semanticEvent(control, source, rawEvent, 'user'); + if isfield(control, 'getValue') + event.value = control.getValue(); + end + appCallback(control, event); + end +end + +function setOriginalCallbackName(handle, callback) + if isempty(callback) || ~isa(callback, 'function_handle') + return; + end + try + setappdata(handle, 'labkit_ui_original_callback_name', func2str(callback)); + catch + end +end + +function callback = semanticActionCallback(id, appCallback) + callback = @wrapped; + + function wrapped(source, rawEvent) + ui = currentUiRegistry(source); + control = ui.controls.(id); + event = semanticEvent(control, source, rawEvent, 'user'); + event.action = id; + if ~isempty(appCallback) + appCallback(control, event); + end + end +end + +function callback = semanticPathChooseCallback(id, appCallback) + callback = @wrapped; + + function wrapped(source, rawEvent) + ui = currentUiRegistry(source); + control = ui.controls.(id); + paths = choosePaths(control); + if isempty(paths) + return; + end + control = applyPathSelection(control, paths, true); + ui.controls.(id) = control; + setappdata(ui.figure, 'labkitUiRegistry', ui); + event = semanticEvent(control, source, rawEvent, 'user'); + event.action = 'choose'; + event.mode = optionValue(control.props, 'mode', ''); + event.paths = paths; + event.selection = currentPathValues(control); + event.value = event.selection; + if ~isempty(appCallback) + appCallback(control, event); + end + end +end + +function callback = semanticPathClearCallback(id, appCallback) + callback = @wrapped; + + function wrapped(source, rawEvent) + ui = currentUiRegistry(source); + control = ui.controls.(id); + control = applyPathSelection(control, {}, true); + ui.controls.(id) = control; + setappdata(ui.figure, 'labkitUiRegistry', ui); + event = semanticEvent(control, source, rawEvent, 'user'); + event.action = 'clear'; + event.mode = optionValue(control.props, 'mode', ''); + event.paths = {}; + event.selection = {}; + event.value = {}; + if ~isempty(appCallback) + appCallback(control, event); + end + end +end + +function callback = semanticPathSelectionCallback(id, appCallback) + if isempty(appCallback) + callback = []; + return; + end + callback = @wrapped; + + function wrapped(source, rawEvent) + ui = currentUiRegistry(source); + control = ui.controls.(id); + event = semanticEvent(control, source, rawEvent, 'user'); + event.action = 'select'; + event.mode = optionValue(control.props, 'mode', ''); + event.paths = currentPathValues(control); + event.selection = event.paths; + event.value = event.paths; + appCallback(control, event); + end +end + +function control = applyPathSelection(control, paths, updateStatus) + items = normalizedPaths(paths); + emptyText = optionValue(control.props, 'emptyText', 'No selection'); + if isempty(items) + control.listbox.Items = {emptyText}; + if strcmp(control.listbox.Multiselect, 'on') + control.listbox.Value = {emptyText}; + else + control.listbox.Value = emptyText; + end + else + control.listbox.Items = items; + if strcmp(control.listbox.Multiselect, 'on') + control.listbox.Value = items; + else + control.listbox.Value = items{1}; + end + end + if updateStatus + control.status.Value = pathStatusText(control.props, items); + end +end + +function paths = choosePaths(control) + props = control.props; + if isfield(props, 'dialogProvider') && isa(props.dialogProvider, 'function_handle') + paths = normalizedPaths(props.dialogProvider(props)); + return; + end + + mode = optionValue(props, 'mode', 'singleFile'); + switch mode + case 'singleFile' + paths = chooseFiles(props, false); + case 'multiFile' + paths = chooseFiles(props, true); + case {'folder', 'outputFolder'} + paths = chooseFolder(optionValue(props, 'startPath', pwd)); + case 'multiFolder' + paths = chooseMultipleFolders(optionValue(props, 'startPath', pwd)); + otherwise + error('labkit:ui:app:InvalidPathMode', ... + 'Unsupported pathPanel mode "%s".', mode); + end +end + +function paths = chooseFiles(props, allowMulti) + filters = optionValue(props, 'filters', {'*.*', 'All files'}); + titleText = optionValue(props, 'dialogTitle', chooseButtonText(props)); + startPath = optionValue(props, 'startPath', pwd); + if allowMulti + [files, folder] = uigetfile(filters, titleText, startPath, 'MultiSelect', 'on'); + else + [files, folder] = uigetfile(filters, titleText, startPath); + end + if isequal(files, 0) || isequal(folder, 0) + paths = {}; + return; + end + if ischar(files) || isstring(files) + files = {char(string(files))}; + end + files = reshape(files, 1, []); + paths = cell(1, numel(files)); + for k = 1:numel(files) + paths{k} = fullfile(folder, files{k}); + end +end + +function paths = chooseFolder(startPath) + folder = uigetdir(startPath, 'Choose folder'); + if isequal(folder, 0) + paths = {}; + else + paths = {folder}; + end +end + +function paths = chooseMultipleFolders(startPath) + paths = {}; + nextPath = startPath; + while true + folder = uigetdir(nextPath, sprintf('Choose folder %d', numel(paths) + 1)); + if isequal(folder, 0) + break; + end + paths{end + 1} = folder; + nextPath = folder; + choice = questdlg('Add another folder?', 'Select folders', ... + 'Add another', 'Done', 'Done'); + if ~strcmp(choice, 'Add another') + break; + end + end + paths = unique(paths, 'stable'); +end + +function paths = normalizedPaths(paths) + if isempty(paths) + paths = {}; + return; + end + if ischar(paths) || isstring(paths) + paths = cellstr(string(paths)); + elseif ~iscell(paths) + paths = cellstr(string(paths)); + end + paths = cellfun(@(value) char(string(value)), reshape(paths, 1, []), ... + 'UniformOutput', false); +end + +function text = pathStatusText(props, paths) + if isempty(paths) + text = optionValue(props, 'status', 'No selection'); + elseif numel(paths) == 1 + text = paths{1}; + else + text = sprintf('%d selected', numel(paths)); + end +end + +function values = currentPathValues(control) + values = {}; + if isfield(control, 'listbox') && isvalid(control.listbox) + if isempty(control.listbox.Items) || ... + (numel(control.listbox.Items) == 1 && strcmp(control.listbox.Items{1}, ... + optionValue(control.props, 'emptyText', 'No selection'))) + values = {}; + return; + end + values = cellstr(string(control.listbox.Value)); + end +end + +function ui = currentUiRegistry(source) + fig = ancestor(source, 'figure'); + if isempty(fig) || ~isappdata(fig, 'labkitUiRegistry') + error('labkit:ui:app:MissingRegistry', ... + 'UI registry appdata was not found on the current figure.'); + end + ui = getappdata(fig, 'labkitUiRegistry'); +end + +function applyCommonValueProps(control, props) + if isfield(props, 'items') && isprop(control, 'Items') + control.Items = cellstr(string(props.items)); + end + if isfield(props, 'limits') && isprop(control, 'Limits') + control.Limits = props.limits; + end + if isfield(props, 'step') && isprop(control, 'Step') + control.Step = props.step; + end + if isfield(props, 'valueDisplayFormat') && isprop(control, 'ValueDisplayFormat') + control.ValueDisplayFormat = props.valueDisplayFormat; + end + if isfield(props, 'value') && isprop(control, 'Value') + control.Value = props.value; + end +end + +function text = chooseButtonText(props) + if isfield(props, 'chooseLabel') + text = char(string(props.chooseLabel)); + return; + end + + mode = optionValue(props, 'mode', 'singleFile'); + switch mode + case {'folder', 'multiFolder', 'outputFolder'} + text = 'Choose folder'; + otherwise + text = 'Choose files'; + end +end + +function value = pathMultiselect(props) + mode = optionValue(props, 'selectionMode', defaultSelectionMode( ... + optionValue(props, 'mode', 'singleFile'))); + if strcmp(mode, 'multiple') + value = 'on'; + else + value = 'off'; + end +end + +function mode = defaultSelectionMode(pathMode) + if any(strcmp(pathMode, {'multiFile', 'multiFolder'})) + mode = 'multiple'; + else + mode = 'single'; + end +end + +function lines = textLines(value) + if isstring(value) + lines = cellstr(value); + elseif ischar(value) + lines = {value}; + elseif iscell(value) + lines = cellstr(string(value)); + else + lines = cellstr(string(value)); + end +end + +function adapter = baseAdapter(spec, kind) + adapter = struct(); + adapter.id = spec.id; + adapter.kind = kind; + adapter.spec = spec; + adapter.props = spec.props; +end + +function adapter = registerValueControl(spec, handle, valueHandle, label) + adapter = baseAdapter(spec, 'field'); + adapter.handle = handle; + adapter.valueHandle = valueHandle; + adapter.label = label; +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end + +function tf = isDebugEnabled(debugContext) + tf = isstruct(debugContext) && isfield(debugContext, 'enabled') && ... + logical(debugContext.enabled); +end + +function text = onOff(value) + if islogical(value) && isscalar(value) + if value + text = 'on'; + else + text = 'off'; + end + else + text = char(string(value)); + end +end diff --git a/+labkit/+ui/+app/private/buildControlTabs.m b/+labkit/+ui/+app/private/buildControlTabs.m new file mode 100644 index 0000000..4e30fbf --- /dev/null +++ b/+labkit/+ui/+app/private/buildControlTabs.m @@ -0,0 +1,15 @@ +% Private UI app helper. Expected caller: labkit.ui.app.create. Inputs are the +% current UI registry, validated tab specs, and debug context. Output is the +% updated UI registry after control-tab sections and controls are built. +function ui = buildControlTabs(ui, tabs, debug) + for iTab = 1:numel(tabs) + tabSpec = tabs{iTab}; + grid = ui.([tabSpec.id 'Grid']); + ui.tabs.(tabSpec.id) = struct('id', tabSpec.id, ... + 'spec', tabSpec, 'grid', grid, ... + 'tab', ui.([tabSpec.id 'Tab'])); + for iSection = 1:numel(tabSpec.children) + ui = buildSection(ui, tabSpec.children{iSection}, grid, iSection, debug); + end + end +end diff --git a/+labkit/+ui/+app/private/buildSection.m b/+labkit/+ui/+app/private/buildSection.m new file mode 100644 index 0000000..3e3d21d --- /dev/null +++ b/+labkit/+ui/+app/private/buildSection.m @@ -0,0 +1,76 @@ +% Private UI app helper. Expected caller: buildControlTabs. Inputs are the +% current UI registry, one validated section spec, a parent grid, target row, +% and debug context. Output is the updated registry after the section and its +% children are built. +function ui = buildSection(ui, sectionSpec, parentGrid, row, debug) + childCount = max(1, numel(sectionSpec.children)); + panel = uipanel(parentGrid, ... + 'Title', optionValue(sectionSpec.props, 'title', sectionSpec.id)); + panel.Layout.Row = row; + panel.Layout.Column = 1; + + grid = uigridlayout(panel, [childCount 2]); + grid.RowHeight = sectionRowHeights(sectionSpec.children); + grid.ColumnWidth = {145, '1x'}; + grid.RowSpacing = optionValue(sectionSpec.props, 'rowSpacing', 8); + grid.ColumnSpacing = optionValue(sectionSpec.props, 'columnSpacing', 8); + grid.Padding = optionValue(sectionSpec.props, 'padding', [8 8 8 8]); + + adapter = baseAdapter(sectionSpec, 'section'); + adapter.panel = panel; + adapter.grid = grid; + ui.sections.(sectionSpec.id) = adapter; + ui.controls.(sectionSpec.id) = adapter; + + for iChild = 1:numel(sectionSpec.children) + ui = buildControl(ui, sectionSpec.children{iChild}, grid, iChild, debug); + end +end + +function rowHeight = sectionRowHeights(children) + count = max(1, numel(children)); + rowHeight = repmat({'fit'}, 1, count); + for k = 1:numel(children) + rowHeight{k} = childRowHeight(children{k}); + end +end + +function value = childRowHeight(spec) + switch spec.kind + case {'previewArea', 'resultTable', 'logPanel', 'statusPanel', 'pathPanel'} + defaultValue = '1x'; + otherwise + defaultValue = 'fit'; + end + value = heightValue(spec.props, defaultValue); +end + +function value = heightValue(props, defaultValue) + value = optionValue(props, 'height', defaultValue); + if ischar(value) || isstring(value) + text = char(string(value)); + switch lower(text) + case {'fit', 'fixed'} + value = 'fit'; + case {'flex', 'fill', 'grow'} + value = '1x'; + otherwise + value = text; + end + end +end + +function adapter = baseAdapter(spec, kind) + adapter = struct(); + adapter.id = spec.id; + adapter.kind = kind; + adapter.spec = spec; + adapter.props = spec.props; +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+ui/+app/private/buildShellFromSpec.m b/+labkit/+ui/+app/private/buildShellFromSpec.m new file mode 100644 index 0000000..6b3bd68 --- /dev/null +++ b/+labkit/+ui/+app/private/buildShellFromSpec.m @@ -0,0 +1,95 @@ +% Private UI app helper. Expected caller: labkit.ui.app.create. Inputs are a +% validated app spec and optional debug context. Output is the initial UI +% registry shell before controls are populated. +function ui = buildShellFromSpec(spec, debug) + appProps = spec.props; + tabs = appProps.controlTabs; + workspaceSpec = appProps.workspace; + workspaceChildren = workspaceSpec.children; + + shell = createTabbedWorkbenchShell( ... + optionValue(appProps, 'title', spec.id), ... + optionValue(appProps, 'position', [90 70 1200 800]), ... + optionValue(appProps, 'leftWidth', 420), ... + struct('controlsPanel', 'Controls', ... + 'rightPanel', optionValue(workspaceSpec.props, 'title', 'Workspace')), ... + tabShellSpecs(tabs), ... + [max(1, numel(workspaceChildren)) 1], ... + workspaceRowHeights(workspaceChildren), ... + optionValue(workspaceSpec.props, 'rowSpacing', 8)); + + ui = shell; + ui.figure = shell.fig; + ui.spec = spec; + ui.debug = debug; + ui.controls = struct(); + ui.sections = struct(); + ui.tabs = struct(); + ui.workspace = struct('id', workspaceSpec.id, ... + 'spec', workspaceSpec, 'grid', shell.rightGrid); +end + +function specs = tabShellSpecs(tabs) + specs = repmat(struct( ... + 'key', '', 'title', '', 'gridSize', [1 1], ... + 'rowHeight', {{'fit'}}, 'columnWidth', {{'1x'}}, ... + 'resize', 'none'), 1, numel(tabs)); + for k = 1:numel(tabs) + tabSpec = tabs{k}; + rowCount = max(1, numel(tabSpec.children)); + specs(k).key = tabSpec.id; + specs(k).title = optionValue(tabSpec.props, 'title', tabSpec.id); + specs(k).gridSize = [rowCount 1]; + specs(k).rowHeight = tabRowHeights(tabSpec.children); + specs(k).columnWidth = {'1x'}; + specs(k).resize = optionValue(tabSpec.props, 'resize', 'none'); + end +end + +function rowHeight = tabRowHeights(children) + count = max(1, numel(children)); + rowHeight = repmat({'fit'}, 1, count); + for k = 1:numel(children) + rowHeight{k} = heightValue(children{k}.props, 'fit'); + end +end + +function rowHeight = workspaceRowHeights(children) + count = max(1, numel(children)); + rowHeight = repmat({'1x'}, 1, count); + for k = 1:numel(children) + rowHeight{k} = childRowHeight(children{k}); + end +end + +function value = childRowHeight(spec) + switch spec.kind + case {'previewArea', 'resultTable', 'logPanel', 'statusPanel', 'pathPanel'} + defaultValue = '1x'; + otherwise + defaultValue = 'fit'; + end + value = heightValue(spec.props, defaultValue); +end + +function value = heightValue(props, defaultValue) + value = optionValue(props, 'height', defaultValue); + if ischar(value) || isstring(value) + text = char(string(value)); + switch lower(text) + case {'fit', 'fixed'} + value = 'fit'; + case {'flex', 'fill', 'grow'} + value = '1x'; + otherwise + value = text; + end + end +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+ui/+app/private/buildWorkspace.m b/+labkit/+ui/+app/private/buildWorkspace.m new file mode 100644 index 0000000..3a59541 --- /dev/null +++ b/+labkit/+ui/+app/private/buildWorkspace.m @@ -0,0 +1,204 @@ +% Private UI app helper. Expected caller: labkit.ui.app.create. Inputs are the +% current UI registry, one validated workspace spec, and debug context. Output +% is the updated registry after workspace children are built. +function ui = buildWorkspace(ui, workspaceSpec, debug) + for iChild = 1:numel(workspaceSpec.children) + childSpec = workspaceSpec.children{iChild}; + switch childSpec.kind + case 'previewArea' + ui = buildPreviewArea(ui, childSpec, ui.rightGrid, iChild); + case {'resultTable', 'statusPanel', 'logPanel', 'custom'} + ui = buildControl(ui, childSpec, ui.rightGrid, iChild, debug); + otherwise + error('labkit:ui:app:UnsupportedWorkspaceChild', ... + 'Unsupported workspace child kind "%s".', childSpec.kind); + end + end +end + +function ui = buildPreviewArea(ui, previewSpec, parentGrid, row) + props = previewSpec.props; + axisIds = previewAxisIds(props); + count = numel(axisIds); + panel = uipanel(parentGrid, 'Title', optionValue(props, 'title', previewSpec.id)); + panel.Layout.Row = row; + panel.Layout.Column = 1; + + hasModes = isfield(props, 'viewModes') && ~isempty(props.viewModes); + gridRows = 1 + double(hasModes); + grid = uigridlayout(panel, [gridRows 1]); + grid.Padding = [8 8 8 8]; + if hasModes + grid.RowHeight = {'fit', '1x'}; + modeDropDown = uidropdown(grid, 'Items', cellstr(string(props.viewModes))); + modeDropDown.ValueChangedFcn = semanticPreviewModeCallback( ... + previewSpec.id, optionValue(props, 'onModeChange', [])); + modeDropDown.Layout.Row = 1; + modeDropDown.Layout.Column = 1; + axesHostRow = 2; + else + grid.RowHeight = {'1x'}; + modeDropDown = []; + axesHostRow = 1; + end + + axesGrid = previewAxesGrid(grid, props.layout, count); + axesGrid.Layout.Row = axesHostRow; + axesGrid.Layout.Column = 1; + axesHandles = gobjects(1, count); + axesById = struct(); + for k = 1:count + ax = uiaxes(axesGrid); + ax.Layout.Row = axesRow(props.layout, k); + ax.Layout.Column = axesColumn(props.layout, k); + title(ax, axisTitle(previewSpec, axisIds, k)); + xlabel(ax, axisLabel(props, 'xLabels', k)); + ylabel(ax, axisLabel(props, 'yLabels', k)); + enableAxesPopout(ax); + axesHandles(k) = ax; + axesById.(axisIds{k}) = ax; + end + + adapter = baseAdapter(previewSpec, 'previewArea'); + adapter.panel = panel; + adapter.grid = grid; + adapter.axesGrid = axesGrid; + adapter.axes = axesHandles; + adapter.axesById = axesById; + adapter.primaryAxes = axesHandles(1); + adapter.viewModeDropDown = modeDropDown; + if ~isempty(modeDropDown) + adapter.valueHandle = modeDropDown; + end + ui.controls.(previewSpec.id) = adapter; +end + +function grid = previewAxesGrid(parent, layout, count) + switch layout + case 'single' + grid = uigridlayout(parent, [1 1]); + grid.RowHeight = {'1x'}; + grid.ColumnWidth = {'1x'}; + case 'pair' + grid = uigridlayout(parent, [1 count]); + grid.RowHeight = {'1x'}; + grid.ColumnWidth = repmat({'1x'}, 1, count); + case 'stack' + grid = uigridlayout(parent, [count 1]); + grid.RowHeight = repmat({'1x'}, 1, count); + grid.ColumnWidth = {'1x'}; + otherwise + error('labkit:ui:app:InvalidPreviewLayout', ... + 'Unsupported previewArea layout "%s".', layout); + end + grid.Padding = [0 0 0 0]; +end + +function row = axesRow(layout, index) + if strcmp(layout, 'stack') + row = index; + else + row = 1; + end +end + +function column = axesColumn(layout, index) + if strcmp(layout, 'pair') + column = index; + else + column = 1; + end +end + +function ids = previewAxisIds(props) + if isfield(props, 'axisIds') && ~isempty(props.axisIds) + ids = cellstr(string(props.axisIds)); + validateAxisIds(ids); + return; + end + + switch props.layout + case 'single' + defaultCount = 1; + case 'pair' + defaultCount = 2; + otherwise + defaultCount = optionValue(props, 'count', 2); + end + count = optionValue(props, 'count', defaultCount); + ids = cell(1, count); + for k = 1:count + ids{k} = sprintf('axis%d', k); + end +end + +function validateAxisIds(ids) + for k = 1:numel(ids) + if ~isvarname(ids{k}) + error('labkit:ui:app:InvalidAxisId', ... + 'previewArea axis id "%s" must be a valid MATLAB field name.', ids{k}); + end + end +end + +function titleText = axisTitle(previewSpec, axisIds, index) + titles = optionValue(previewSpec.props, 'axisTitles', {}); + if numel(titles) >= index + titleText = char(string(titles{index})); + elseif numel(axisIds) == 1 + titleText = optionValue(previewSpec.props, 'title', previewSpec.id); + else + titleText = axisIds{index}; + end +end + +function labelText = axisLabel(props, fieldName, index) + labels = optionValue(props, fieldName, {}); + if numel(labels) >= index + labelText = char(string(labels{index})); + else + labelText = ''; + end +end + +function adapter = baseAdapter(spec, kind) + adapter = struct(); + adapter.id = spec.id; + adapter.kind = kind; + adapter.spec = spec; + adapter.props = spec.props; +end + +function callback = semanticPreviewModeCallback(id, appCallback) + if isempty(appCallback) + callback = []; + return; + end + callback = @wrapped; + + function wrapped(source, rawEvent) + ui = currentUiRegistry(source); + control = ui.controls.(id); + event = semanticEvent(control, source, rawEvent, 'user'); + event.action = 'mode'; + event.mode = source.Value; + event.value = source.Value; + appCallback(control, event); + end +end + +function ui = currentUiRegistry(source) + fig = ancestor(source, 'figure'); + if isempty(fig) || ~isappdata(fig, 'labkitUiRegistry') + error('labkit:ui:app:MissingRegistry', ... + 'UI registry appdata was not found on the current figure.'); + end + ui = getappdata(fig, 'labkitUiRegistry'); +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m b/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m index 600901d..cb3eaa0 100644 --- a/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m +++ b/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m @@ -6,14 +6,14 @@ %CREATETABBEDWORKBENCHSHELL Build the private tabbed workbench skeleton. % % Called by: -% labkit.ui.app.createShell +% labkit.ui.app.create through buildShellFromSpec. % % Inputs: % figName - figure name/title. % figPosition - uifigure Position vector. % leftWidth - initial fixed width of the left control panel. % labels - struct with controlsPanel and rightPanel text. -% tabSpecs - struct array from labkit.ui.app.tab/default shell specs. +% tabSpecs - internal tab specs derived from UI 2.0 app specs. % rightGridSize - right-side uigridlayout size. % rightRowHeight - right-side grid RowHeight cell array. % rightRowSpacing - right-side grid RowSpacing scalar. @@ -24,8 +24,8 @@ % % Notes: % Logical tab rows are expanded with physical resize-handle rows here. -% App code should place controls through public helpers that call -% labkit.ui.view.place rather than depending on physical row indices. +% App code should use UI 2.0 specs rather than depending on physical row +% indices. ui = struct(); ui.fig = uifigure('Name', figName, 'Position', figPosition); diff --git a/+labkit/+ui/+app/private/enableAxesPopout.m b/+labkit/+ui/+app/private/enableAxesPopout.m new file mode 100644 index 0000000..890c412 --- /dev/null +++ b/+labkit/+ui/+app/private/enableAxesPopout.m @@ -0,0 +1,71 @@ +% Private UI app helper. Expected caller: buildWorkspace preview-area setup. +% Input is a UI axes. Output mutates the axes and children in place so the +% standard LabKit popout action remains available without a public axes helper. +function enableAxesPopout(ax) + if isempty(ax) || ~isvalid(ax) + return; + end + menu = ax.ContextMenu; + if isappdata(ax, 'labkitAxesPopoutEnabled') && ... + getappdata(ax, 'labkitAxesPopoutEnabled') && ... + ~isempty(menu) && isvalid(menu) && ... + ~isempty(findall(menu, 'Type', 'uimenu', 'Tag', 'labkitAxesPopoutMenu')) + attachMenuToAxesChildren(ax, menu); + return; + end + + fig = ancestor(ax, 'figure'); + if isempty(fig) + return; + end + + if isempty(menu) || ~isvalid(menu) + menu = uicontextmenu(fig); + ax.ContextMenu = menu; + end + + existing = findall(menu, 'Type', 'uimenu', 'Tag', 'labkitAxesPopoutMenu'); + if isempty(existing) + uimenu(menu, ... + 'Text', 'Open axes in new figure', ... + 'Tag', 'labkitAxesPopoutMenu', ... + 'MenuSelectedFcn', @(~,~) popoutAxes(ax)); + end + attachMenuToAxesChildren(ax, menu); + installChildrenListener(ax, menu); + setappdata(ax, 'labkitAxesPopoutEnabled', true); +end + +function installChildrenListener(ax, menu) + if isappdata(ax, 'labkitAxesPopoutChildrenListener') + listener = getappdata(ax, 'labkitAxesPopoutChildrenListener'); + if ~isempty(listener) + return; + end + end + try + listener = addlistener(ax, 'Children', 'PostSet', ... + @(~,~) attachMenuToAxesChildren(ax, menu)); + setappdata(ax, 'labkitAxesPopoutChildrenListener', listener); + catch + end +end + +function attachMenuToAxesChildren(ax, menu) + if isempty(ax) || ~isvalid(ax) || isempty(menu) || ~isvalid(menu) + return; + end + children = ax.Children; + for k = 1:numel(children) + child = children(k); + if ~isvalid(child) || ~isprop(child, 'ContextMenu') + continue; + end + if isempty(child.ContextMenu) || ~isvalid(child.ContextMenu) + try + child.ContextMenu = menu; + catch + end + end + end +end diff --git a/+labkit/+ui/+app/private/popoutAxes.m b/+labkit/+ui/+app/private/popoutAxes.m new file mode 100644 index 0000000..7dd17d1 --- /dev/null +++ b/+labkit/+ui/+app/private/popoutAxes.m @@ -0,0 +1,120 @@ +% Private UI app helper. Expected caller: enableAxesPopout. Input is a source +% UI axes. Output is a standalone MATLAB figure containing copied axes content. +function newFig = popoutAxes(srcAx) + if isempty(srcAx) || ~isvalid(srcAx) + error('labkit:ui:InvalidAxes', 'Source axes is not valid.'); + end + + titleText = axisLabelText(srcAx.Title); + if strlength(titleText) == 0 + titleText = "LabKit Plot"; + end + + newFig = figure('Name', char(titleText), 'Color', 'w'); + dstAx = axes('Parent', newFig); + copyAxesState(srcAx, dstAx); + + children = flipud(srcAx.Children(:)); + if ~isempty(children) + copyobj(children, dstAx); + end + applyAxesState(srcAx, dstAx); +end + +function copyAxesState(srcAx, dstAx) + props = {'XScale','YScale','ZScale','XDir','YDir','ZDir', ... + 'XLim','YLim','ZLim','CLim', ... + 'View','Box','XGrid','YGrid','ZGrid','Color','XColor','YColor','ZColor', ... + 'LineWidth','FontName','FontSize','FontWeight','FontAngle'}; + for k = 1:numel(props) + try + dstAx.(props{k}) = srcAx.(props{k}); + catch + end + end + try + colormap(dstAx, colormap(srcAx)); + catch + end +end + +function applyAxesState(srcAx, dstAx) + title(dstAx, axisLabelText(srcAx.Title)); + xlabel(dstAx, axisLabelText(srcAx.XLabel)); + ylabel(dstAx, axisLabelText(srcAx.YLabel)); + zlabel(dstAx, axisLabelText(srcAx.ZLabel)); + + try + dstAx.XLimMode = srcAx.XLimMode; + dstAx.YLimMode = srcAx.YLimMode; + dstAx.ZLimMode = srcAx.ZLimMode; + dstAx.CLimMode = srcAx.CLimMode; + catch + end + applyAspectRatio(srcAx, dstAx); + addLegendIfNeeded(dstAx); +end + +function applyAspectRatio(srcAx, dstAx) + if hasImageContent(srcAx) + lockImageAspectRatio(srcAx, dstAx); + else + unlockAspectRatio(dstAx); + end +end + +function unlockAspectRatio(ax) + try + ax.DataAspectRatioMode = 'auto'; + ax.PlotBoxAspectRatioMode = 'auto'; + ax.ActivePositionProperty = 'outerposition'; + catch + end +end + +function lockImageAspectRatio(srcAx, dstAx) + try + dstAx.DataAspectRatio = srcAx.DataAspectRatio; + dstAx.DataAspectRatioMode = 'manual'; + dstAx.PlotBoxAspectRatioMode = 'auto'; + dstAx.ActivePositionProperty = 'outerposition'; + catch + axis(dstAx, 'image'); + end +end + +function tf = hasImageContent(ax) + children = ax.Children; + tf = false; + for k = 1:numel(children) + if isgraphics(children(k), 'image') + tf = true; + return; + end + end +end + +function text = axisLabelText(labelHandle) + value = labelHandle.String; + if iscell(value) + text = string(strjoin(value, newline)); + else + text = string(value); + end +end + +function addLegendIfNeeded(ax) + children = ax.Children; + names = strings(0, 1); + for k = 1:numel(children) + if isprop(children(k), 'DisplayName') + name = string(children(k).DisplayName); + if strlength(name) > 0 && ~startsWith(name, "_") + names(end + 1, 1) = name; + end + end + end + if ~isempty(names) + legend(ax, 'show', 'Interpreter', 'none'); + end +end diff --git a/+labkit/+ui/+app/private/semanticEvent.m b/+labkit/+ui/+app/private/semanticEvent.m new file mode 100644 index 0000000..2a6ff23 --- /dev/null +++ b/+labkit/+ui/+app/private/semanticEvent.m @@ -0,0 +1,38 @@ +% Private UI app helper. Expected callers: UI 2.0 callback wrappers under +% +labkit/+ui/+app/private. Inputs are a semantic control adapter, the +% originating MATLAB UI source/raw event, and a semantic source label. Output is +% the normalized event payload seen by app callbacks. +function event = semanticEvent(control, source, rawEvent, sourceKind) + event = struct(); + event.id = control.id; + event.kind = control.kind; + event.source = sourceKind; + event.value = currentValue(source); + event.previousValue = previousValue(rawEvent); + event.ui = currentUiRegistry(source); + event.rawEvent = rawEvent; +end + +function value = currentValue(source) + if ~isempty(source) && isprop(source, 'Value') + value = source.Value; + else + value = []; + end +end + +function value = previousValue(rawEvent) + value = []; + if ~isempty(rawEvent) && isprop(rawEvent, 'PreviousValue') + value = rawEvent.PreviousValue; + end +end + +function ui = currentUiRegistry(source) + fig = ancestor(source, 'figure'); + if isempty(fig) || ~isappdata(fig, 'labkitUiRegistry') + error('labkit:ui:app:MissingRegistry', ... + 'UI registry appdata was not found on the current figure.'); + end + ui = getappdata(fig, 'labkitUiRegistry'); +end diff --git a/+labkit/+ui/+app/private/validateAppSpec.m b/+labkit/+ui/+app/private/validateAppSpec.m new file mode 100644 index 0000000..2d1793d --- /dev/null +++ b/+labkit/+ui/+app/private/validateAppSpec.m @@ -0,0 +1,106 @@ +% Private UI app helper. Expected caller: labkit.ui.app.create. Input is one +% data-only app spec. Output is validation-by-success; errors are raised for +% duplicate ids, invalid tree shape, or unsupported child relationships before +% GUI construction begins. +function validateAppSpec(spec) + assertSpecKind(spec, 'app'); + if ~isfield(spec.props, 'controlTabs') || ... + ~iscell(spec.props.controlTabs) || ~isrow(spec.props.controlTabs) + error('labkit:ui:app:InvalidSpec', ... + 'app spec requires controlTabs as a cell row vector.'); + end + if ~isfield(spec.props, 'workspace') || ... + ~isstruct(spec.props.workspace) || ~isscalar(spec.props.workspace) + error('labkit:ui:app:InvalidSpec', ... + 'app spec requires one workspace spec.'); + end + + assertSpecKind(spec.props.workspace, 'workspace'); + ids = collectSpecIds(spec, {}); + duplicate = firstDuplicate(ids); + if strlength(duplicate) > 0 + error('labkit:ui:app:DuplicateId', ... + 'Duplicate UI 2.0 spec id "%s".', char(duplicate)); + end + validateTreeShape(spec); +end + +function ids = collectSpecIds(spec, ids) + ids{end + 1} = spec.id; + if strcmp(spec.kind, 'app') && isfield(spec.props, 'controlTabs') + for k = 1:numel(spec.props.controlTabs) + ids = collectSpecIds(spec.props.controlTabs{k}, ids); + end + ids = collectSpecIds(spec.props.workspace, ids); + end + for k = 1:numel(spec.children) + ids = collectSpecIds(spec.children{k}, ids); + end +end + +function duplicate = firstDuplicate(ids) + duplicate = ""; + seen = containers.Map(); + for k = 1:numel(ids) + id = ids{k}; + if isKey(seen, id) + duplicate = string(id); + return; + end + seen(id) = true; + end +end + +function validateTreeShape(spec) + assertCommonSpec(spec); + switch spec.kind + case 'app' + for k = 1:numel(spec.props.controlTabs) + assertSpecKind(spec.props.controlTabs{k}, 'tab'); + validateTreeShape(spec.props.controlTabs{k}); + end + validateTreeShape(spec.props.workspace); + case 'workspace' + validateChildKinds(spec, {'previewArea', 'resultTable', ... + 'statusPanel', 'logPanel', 'custom'}); + case 'tab' + validateChildKinds(spec, {'section'}); + case 'section' + validateChildKinds(spec, {'field', 'rangeField', 'action', ... + 'actionGroup', 'pathPanel', 'resultTable', 'statusPanel', ... + 'logPanel', 'custom'}); + case 'actionGroup' + validateChildKinds(spec, {'action'}); + otherwise + validateChildKinds(spec, {}); + end +end + +function validateChildKinds(spec, allowedKinds) + for k = 1:numel(spec.children) + child = spec.children{k}; + if ~any(strcmp(child.kind, allowedKinds)) + error('labkit:ui:app:InvalidChildKind', ... + 'Spec "%s" cannot contain child kind "%s".', spec.id, child.kind); + end + validateTreeShape(child); + end +end + +function assertSpecKind(spec, kind) + assertCommonSpec(spec); + if ~strcmp(spec.kind, kind) + error('labkit:ui:app:InvalidSpecKind', ... + 'Expected %s spec, got "%s".', kind, spec.kind); + end +end + +function assertCommonSpec(spec) + if ~isstruct(spec) || ~isscalar(spec) || ... + ~all(isfield(spec, {'kind', 'id', 'props', 'children', 'slots'})) || ... + ~iscell(spec.children) || ... + ~(isempty(spec.children) || isrow(spec.children)) + error('labkit:ui:app:InvalidSpec', ... + 'UI 2.0 specs must be scalar structs with cell row children.'); + end +end diff --git a/+labkit/+ui/+app/tab.m b/+labkit/+ui/+app/tab.m deleted file mode 100644 index 1a966bc..0000000 --- a/+labkit/+ui/+app/tab.m +++ /dev/null @@ -1,64 +0,0 @@ -function spec = tab(key, titleText, gridSize, rowHeight, opts) -%TABSPEC Build a tab specification for the shared workbench shell. -% -% Usage: -% spec = labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', ... -% [4 1], {240, 220, 280, 160}); -% -% Inputs: -% key - valid field-name style identifier used in the returned ui struct. -% titleText - visible tab title. -% gridSize - [rows columns] for the tab content grid. -% rowHeight - optional row-height cell/numeric/string row; default all fit. -% opts - optional struct copied onto the spec. -% -% Options: -% columnWidth - cell row of column widths, default all {'1x'}. -% resize - row-resize behavior: 'betweenRows' default, or 'none'. -% resizeRows - legacy numeric logical-row boundaries. Prefer resize. -% resizeOptions - struct passed to row-resize handle creation. -% padding, rowSpacing, columnSpacing - grid layout properties. -% -% Output: -% spec - struct consumed by createShell spec options tabs. - - if nargin < 4 || isempty(rowHeight) - rowHeight = repmat({'fit'}, 1, gridSize(1)); - end - if nargin < 5 - opts = struct(); - end - optsHasResize = isfield(opts, 'resize'); - optsHasResizeRows = isfield(opts, 'resizeRows'); - - spec = struct( ... - 'key', char(key), ... - 'title', char(titleText), ... - 'gridSize', gridSize, ... - 'rowHeight', {asCellRow(rowHeight)}, ... - 'columnWidth', {repmat({'1x'}, 1, gridSize(2))}, ... - 'resize', 'betweenRows', ... - 'resizeRows', [], ... - 'resizeOptions', struct()); - - fields = fieldnames(opts); - for k = 1:numel(fields) - spec.(fields{k}) = opts.(fields{k}); - end - - if optsHasResizeRows && isempty(opts.resizeRows) && ~optsHasResize - spec.resize = 'none'; - end -end - -function value = asCellRow(value) - if iscell(value) - value = reshape(value, 1, []); - elseif isstring(value) - value = cellstr(reshape(value, 1, [])); - elseif isnumeric(value) - value = num2cell(reshape(value, 1, [])); - else - value = {value}; - end -end diff --git a/+labkit/+ui/+diag/createContext.m b/+labkit/+ui/+diag/createContext.m index a49162d..430b0fe 100644 --- a/+labkit/+ui/+diag/createContext.m +++ b/+labkit/+ui/+diag/createContext.m @@ -95,7 +95,7 @@ function appendTraceToTextLog(line) if isempty(textArea) || ~isvalid(textArea) return; end - labkit.ui.view.update(textArea, 'appendLog', line); + appendTextLog(textArea, line); end end @@ -179,6 +179,14 @@ function appendTraceToTextLog(line) end end +function appendTextLog(textArea, msg) + timestamp = datestr(now, 'HH:MM:SS'); + old = textArea.Value; + old{end + 1} = sprintf('[%s] %s', timestamp, char(msg)); + textArea.Value = old; + drawnow limitrate +end + function wrapped = callbackWrapperForHandle(handle, propName, callback, traceFcn) if isa(callback, 'function_handle') wrapped = @wrappedFunctionHandle; @@ -227,12 +235,26 @@ function appendTraceToTextLog(line) function label = callbackTraceLabel(handle, propName, callback) label = sprintf('%s %s', char(string(propName)), handleLabel(handle)); - callbackName = callbackNameText(callback); + callbackName = originalCallbackName(handle); + if strlength(callbackName) == 0 + callbackName = callbackNameText(callback); + end if strlength(callbackName) > 0 label = sprintf('%s -> %s', label, char(callbackName)); end end +function txt = originalCallbackName(handle) + txt = ""; + try + if isappdata(handle, 'labkit_ui_original_callback_name') + txt = string(getappdata(handle, 'labkit_ui_original_callback_name')); + end + catch + txt = ""; + end +end + function label = handleLabel(handle) label = class(handle); for propName = {'Text', 'Title', 'Name', 'Tag'} diff --git a/+labkit/+ui/+spec/action.m b/+labkit/+ui/+spec/action.m new file mode 100644 index 0000000..6c6c656 --- /dev/null +++ b/+labkit/+ui/+spec/action.m @@ -0,0 +1,23 @@ +function spec = action(id, labelText, onInvoke, varargin) +%ACTION Create an app-command spec. +% +% App-facing contract: +% spec = labkit.ui.spec.action(id, label, onInvoke, opts...) +% +% Inputs: +% id - globally unique action id. +% labelText - command label. +% onInvoke - function handle called as callback(control, event). +% enabled, priority, tooltip - optional app-neutral props. +% +% Output: +% spec - scalar data-only UI spec struct. + + if nargin < 3 + onInvoke = []; + end + props = optionStruct(varargin); + props.label = char(string(labelText)); + props.onInvoke = onInvoke; + spec = makeSpec('action', id, props, {}, struct()); +end diff --git a/+labkit/+ui/+spec/actionGroup.m b/+labkit/+ui/+spec/actionGroup.m new file mode 100644 index 0000000..c2c6ee3 --- /dev/null +++ b/+labkit/+ui/+spec/actionGroup.m @@ -0,0 +1,20 @@ +function spec = actionGroup(id, children, varargin) +%ACTIONGROUP Create an app-command group spec. +% +% App-facing contract: +% spec = labkit.ui.spec.actionGroup(id, children, opts...) +% +% Inputs: +% id - globally unique group id. +% children - cell row vector of action specs. +% opts - group options such as orientation or enabled state. +% +% Output: +% spec - scalar data-only UI spec struct. + + if nargin < 2 + children = {}; + end + props = optionStruct(varargin); + spec = makeSpec('actionGroup', id, props, children, struct()); +end diff --git a/+labkit/+ui/+spec/app.m b/+labkit/+ui/+spec/app.m new file mode 100644 index 0000000..50d08d9 --- /dev/null +++ b/+labkit/+ui/+spec/app.m @@ -0,0 +1,22 @@ +function spec = app(id, titleText, varargin) +%APP Create a declarative LabKit workbench app spec. +% +% App-facing contract: +% spec = labkit.ui.spec.app(id, title, "controlTabs", tabs, ... +% "workspace", workspace, "position", pos, "leftWidth", width) +% +% Inputs: +% id - globally unique app spec id and valid MATLAB field name. +% titleText - app figure title. +% controlTabs - cell row vector of tab specs. +% workspace - workspace spec for right-side preview/plot/canvas content. +% position - optional figure position, default [90 70 1200 800]. +% leftWidth - optional left control pane width, default 420. +% +% Output: +% spec - scalar data-only UI spec struct consumed by labkit.ui.app.create. + + props = optionStruct(varargin); + props.title = char(string(titleText)); + spec = makeSpec('app', id, props, {}, struct()); +end diff --git a/+labkit/+ui/+spec/custom.m b/+labkit/+ui/+spec/custom.m new file mode 100644 index 0000000..49c370d --- /dev/null +++ b/+labkit/+ui/+spec/custom.m @@ -0,0 +1,39 @@ +function spec = custom(id, builder, varargin) +%CUSTOM Create a custom tool/layout escape-hatch spec. +% +% App-facing contract: +% spec = labkit.ui.spec.custom(id, builder, opts...) +% +% Inputs: +% id - globally unique custom control id. +% builder - named function handle in its own .m file. It is called by the +% framework with parent, id, context, and props. +% opts - app-neutral options for that custom builder. +% +% Output: +% spec - scalar data-only UI spec struct. + + validateBuilder(builder); + props = optionStruct(varargin); + props.builder = builder; + spec = makeSpec('custom', id, props, {}, struct()); +end + +function validateBuilder(builder) + if ~isa(builder, 'function_handle') + error('labkit:ui:spec:InvalidCustomBuilder', ... + 'custom builder must be a function handle.'); + end + + info = functions(builder); + if ismember(info.type, {'anonymous', 'nested', 'scopedfunction'}) + error('labkit:ui:spec:InvalidCustomBuilder', ... + 'custom builder must be a named function in its own .m file.'); + end + + builderFile = which(info.function); + if isempty(builderFile) || ~endsWith(builderFile, '.m') + error('labkit:ui:spec:InvalidCustomBuilder', ... + 'custom builder "%s" must resolve to an .m file.', info.function); + end +end diff --git a/+labkit/+ui/+spec/field.m b/+labkit/+ui/+spec/field.m new file mode 100644 index 0000000..67973c2 --- /dev/null +++ b/+labkit/+ui/+spec/field.m @@ -0,0 +1,37 @@ +function spec = field(id, labelText, varargin) +%FIELD Create a labeled scalar field spec. +% +% App-facing contract: +% spec = labkit.ui.spec.field(id, label, "kind", kind, "value", value, ...) +% +% Inputs: +% id - globally unique field id. +% labelText - field label. +% kind - one of text, number, spinner, dropdown, slider, checkbox, readonly. +% value, items, limits, step, unit, tooltip, onChange - optional props. +% +% Output: +% spec - scalar data-only UI spec struct. + + props = optionStruct(varargin); + props.label = char(string(labelText)); + props.kind = char(string(optionValue(props, 'kind', 'text'))); + validateFieldKind(props.kind); + spec = makeSpec('field', id, props, {}, struct()); +end + +function validateFieldKind(kind) + allowed = {'text', 'number', 'spinner', 'dropdown', 'slider', ... + 'checkbox', 'readonly'}; + if ~any(strcmpi(kind, allowed)) + error('labkit:ui:spec:InvalidFieldKind', ... + 'Unsupported UI 2.0 field kind "%s".', kind); + end +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+ui/+spec/logPanel.m b/+labkit/+ui/+spec/logPanel.m new file mode 100644 index 0000000..c3d024f --- /dev/null +++ b/+labkit/+ui/+spec/logPanel.m @@ -0,0 +1,18 @@ +function spec = logPanel(id, titleText, varargin) +%LOGPANEL Create a read-only log panel spec. +% +% App-facing contract: +% spec = labkit.ui.spec.logPanel(id, title, "value", lines) +% +% Inputs: +% id - globally unique log panel id. +% titleText - log panel title. +% value - initial log lines as text or cellstr, default {'Ready.'}. +% +% Output: +% spec - scalar data-only UI spec struct. + + props = optionStruct(varargin); + props.title = char(string(titleText)); + spec = makeSpec('logPanel', id, props, {}, struct()); +end diff --git a/+labkit/+ui/+spec/pathPanel.m b/+labkit/+ui/+spec/pathPanel.m new file mode 100644 index 0000000..cb2ad0a --- /dev/null +++ b/+labkit/+ui/+spec/pathPanel.m @@ -0,0 +1,58 @@ +function spec = pathPanel(id, labelText, varargin) +%PATHPANEL Create a file/folder chooser panel spec. +% +% App-facing contract: +% spec = labkit.ui.spec.pathPanel(id, label, "mode", mode, callbacks...) +% +% Inputs: +% id - globally unique path-panel id. +% labelText - panel label. +% mode - singleFile, multiFile, folder, multiFolder, or outputFolder. +% selectionMode - single or multiple list selection behavior. Defaults to +% multiple for multiFile/multiFolder and single otherwise. +% filters, chooseLabel, clearLabel, status, emptyText, onChoose, +% onSelectionChange, onClear - optional props. +% +% Output: +% spec - scalar data-only UI spec struct. + + props = optionStruct(varargin); + props.label = char(string(labelText)); + props.mode = char(string(optionValue(props, 'mode', 'singleFile'))); + validateMode(props.mode); + props.selectionMode = char(string(optionValue(props, ... + 'selectionMode', defaultSelectionMode(props.mode)))); + validateSelectionMode(props.selectionMode); + spec = makeSpec('pathPanel', id, props, {}, struct()); +end + +function validateMode(mode) + allowed = {'singleFile', 'multiFile', 'folder', 'multiFolder', 'outputFolder'}; + if ~any(strcmp(mode, allowed)) + error('labkit:ui:spec:InvalidPathPanelMode', ... + 'Unsupported pathPanel mode "%s".', mode); + end +end + +function validateSelectionMode(mode) + allowed = {'single', 'multiple'}; + if ~any(strcmp(mode, allowed)) + error('labkit:ui:spec:InvalidPathPanelSelectionMode', ... + 'Unsupported pathPanel selectionMode "%s".', mode); + end +end + +function mode = defaultSelectionMode(pathMode) + if any(strcmp(pathMode, {'multiFile', 'multiFolder'})) + mode = 'multiple'; + else + mode = 'single'; + end +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+ui/+spec/previewArea.m b/+labkit/+ui/+spec/previewArea.m new file mode 100644 index 0000000..a3378b7 --- /dev/null +++ b/+labkit/+ui/+spec/previewArea.m @@ -0,0 +1,46 @@ +function spec = previewArea(id, titleText, varargin) +%PREVIEWAREA Create a workspace preview/axes area spec. +% +% App-facing contract: +% spec = labkit.ui.spec.previewArea(id, title, "layout", layout, ...) +% +% Inputs: +% id - globally unique preview id. +% titleText - preview area title. +% layout - single, pair, or stack. +% viewModes - optional cell array of user-facing view mode labels. +% onModeChange - optional callback(control, event) for the view-mode +% selector when viewModes are present. +% axisIds - optional cell array of valid axis ids. +% axisTitles, xLabels, yLabels - optional axis label cell arrays. +% count - optional axes count for stack layouts. +% +% Output: +% spec - scalar data-only UI spec struct. + + props = optionStruct(varargin); + props.title = char(string(titleText)); + props.layout = char(string(optionValue(props, 'layout', 'single'))); + validateLayout(props); + spec = makeSpec('previewArea', id, props, {}, struct()); +end + +function validateLayout(props) + allowed = {'single', 'pair', 'stack'}; + if ~any(strcmp(props.layout, allowed)) + error('labkit:ui:spec:InvalidPreviewLayout', ... + 'Unsupported previewArea layout "%s".', props.layout); + end + if isfield(props, 'count') && (~isnumeric(props.count) || ... + ~isscalar(props.count) || props.count < 1 || props.count ~= floor(props.count)) + error('labkit:ui:spec:InvalidPreviewCount', ... + 'previewArea count must be a positive integer scalar.'); + end +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+ui/+spec/private/makeSpec.m b/+labkit/+ui/+spec/private/makeSpec.m new file mode 100644 index 0000000..6edc61e --- /dev/null +++ b/+labkit/+ui/+spec/private/makeSpec.m @@ -0,0 +1,58 @@ +% Private UI spec helper. Expected caller: labkit.ui.spec constructors. +% Builds the canonical scalar UI spec struct. The struct is data only and +% never creates MATLAB graphics handles. +function spec = makeSpec(kind, id, props, children, slots) + if nargin < 3 || isempty(props) + props = struct(); + end + if nargin < 4 + children = {}; + end + if nargin < 5 || isempty(slots) + slots = struct(); + end + + id = normalizeId(id); + children = specChildren(children); + slots = normalizeSlots(slots); + + if ~isstruct(props) || ~isscalar(props) + error('labkit:ui:spec:InvalidProps', ... + 'UI spec props must be a scalar struct.'); + end + + spec = struct( ... + 'kind', char(string(kind)), ... + 'id', id, ... + 'props', props, ... + 'children', {children}, ... + 'slots', slots); +end + +function id = normalizeId(id) + if ~(ischar(id) || (isstring(id) && isscalar(id))) + error('labkit:ui:spec:InvalidId', ... + 'UI spec id must be a text scalar.'); + end + + id = char(string(id)); + if ~isvarname(id) + error('labkit:ui:spec:InvalidId', ... + 'UI spec id "%s" must be a valid MATLAB field name.', id); + end +end + +function slots = normalizeSlots(slots) + if ~isstruct(slots) || ~isscalar(slots) + error('labkit:ui:spec:InvalidSlots', ... + 'UI spec slots must be a scalar struct.'); + end + + names = fieldnames(slots); + for k = 1:numel(names) + value = slots.(names{k}); + if iscell(value) + slots.(names{k}) = specChildren(value); + end + end +end diff --git a/+labkit/+ui/+spec/private/optionStruct.m b/+labkit/+ui/+spec/private/optionStruct.m new file mode 100644 index 0000000..e05dea2 --- /dev/null +++ b/+labkit/+ui/+spec/private/optionStruct.m @@ -0,0 +1,29 @@ +% Private UI spec helper. Expected caller: labkit.ui.spec constructors. +% Converts name/value pairs into a scalar struct without interpreting app +% semantics. Inputs are constructor varargin cells; output is an option struct. +function opts = optionStruct(args) + if nargin < 1 || isempty(args) + opts = struct(); + return; + end + + if mod(numel(args), 2) ~= 0 + error('labkit:ui:spec:InvalidOptions', ... + 'UI spec options must be name/value pairs.'); + end + + opts = struct(); + for k = 1:2:numel(args) + name = args{k}; + if ~(ischar(name) || (isstring(name) && isscalar(name))) + error('labkit:ui:spec:InvalidOptionName', ... + 'UI spec option names must be text scalars.'); + end + field = char(string(name)); + if ~isvarname(field) + error('labkit:ui:spec:InvalidOptionName', ... + 'UI spec option name "%s" is not a valid MATLAB field name.', field); + end + opts.(field) = args{k + 1}; + end +end diff --git a/+labkit/+ui/+spec/private/specChildren.m b/+labkit/+ui/+spec/private/specChildren.m new file mode 100644 index 0000000..3f1cff1 --- /dev/null +++ b/+labkit/+ui/+spec/private/specChildren.m @@ -0,0 +1,23 @@ +% Private UI spec helper. Expected caller: labkit.ui.spec constructors. +% Normalizes heterogeneous child specs to the required cell row vector shape +% and validates the common scalar spec struct contract. +function children = specChildren(children) + if nargin < 1 || isempty(children) + children = {}; + return; + end + + if ~iscell(children) || ~isrow(children) + error('labkit:ui:spec:InvalidChildren', ... + 'UI spec children must be a cell row vector of scalar spec structs.'); + end + + for k = 1:numel(children) + child = children{k}; + if ~isstruct(child) || ~isscalar(child) || ... + ~all(isfield(child, {'kind', 'id', 'props', 'children', 'slots'})) + error('labkit:ui:spec:InvalidChildren', ... + 'UI spec child %d must be a scalar spec struct.', k); + end + end +end diff --git a/+labkit/+ui/+spec/rangeField.m b/+labkit/+ui/+spec/rangeField.m new file mode 100644 index 0000000..311a3c2 --- /dev/null +++ b/+labkit/+ui/+spec/rangeField.m @@ -0,0 +1,19 @@ +function spec = rangeField(id, labelText, varargin) +%RANGEFIELD Create a paired start/end or min/max field spec. +% +% App-facing contract: +% spec = labkit.ui.spec.rangeField(id, label, "value", [lo hi], ...) +% +% Inputs: +% id - globally unique range id. +% labelText - range label. +% value - two-element numeric range value, optional. +% limits, unit, tooltip, onChange - optional app-neutral props. +% +% Output: +% spec - scalar data-only UI spec struct. + + props = optionStruct(varargin); + props.label = char(string(labelText)); + spec = makeSpec('rangeField', id, props, {}, struct()); +end diff --git a/+labkit/+ui/+spec/resultTable.m b/+labkit/+ui/+spec/resultTable.m new file mode 100644 index 0000000..90e8839 --- /dev/null +++ b/+labkit/+ui/+spec/resultTable.m @@ -0,0 +1,19 @@ +function spec = resultTable(id, titleText, varargin) +%RESULTTABLE Create a titled result table spec. +% +% App-facing contract: +% spec = labkit.ui.spec.resultTable(id, title, "columns", columns, ...) +% +% Inputs: +% id - globally unique result table id. +% titleText - table panel title. +% columns - cell array of column names, default {}. +% data - initial table data, default empty cell array. +% +% Output: +% spec - scalar data-only UI spec struct. + + props = optionStruct(varargin); + props.title = char(string(titleText)); + spec = makeSpec('resultTable', id, props, {}, struct()); +end diff --git a/+labkit/+ui/+spec/section.m b/+labkit/+ui/+spec/section.m new file mode 100644 index 0000000..a1a9bfd --- /dev/null +++ b/+labkit/+ui/+spec/section.m @@ -0,0 +1,22 @@ +function spec = section(id, titleText, children, varargin) +%SECTION Create a titled control-section spec. +% +% App-facing contract: +% spec = labkit.ui.spec.section(id, title, children, opts...) +% +% Inputs: +% id - globally unique section id. +% titleText - section title. +% children - cell row vector of control specs. +% opts - app-neutral section options such as height. +% +% Output: +% spec - scalar data-only UI spec struct. + + if nargin < 3 + children = {}; + end + props = optionStruct(varargin); + props.title = char(string(titleText)); + spec = makeSpec('section', id, props, children, struct()); +end diff --git a/+labkit/+ui/+spec/statusPanel.m b/+labkit/+ui/+spec/statusPanel.m new file mode 100644 index 0000000..aff6e33 --- /dev/null +++ b/+labkit/+ui/+spec/statusPanel.m @@ -0,0 +1,18 @@ +function spec = statusPanel(id, titleText, varargin) +%STATUSPANEL Create a read-only status/details panel spec. +% +% App-facing contract: +% spec = labkit.ui.spec.statusPanel(id, title, "value", lines) +% +% Inputs: +% id - globally unique status panel id. +% titleText - status panel title. +% value - initial text or cellstr, default ''. +% +% Output: +% spec - scalar data-only UI spec struct. + + props = optionStruct(varargin); + props.title = char(string(titleText)); + spec = makeSpec('statusPanel', id, props, {}, struct()); +end diff --git a/+labkit/+ui/+spec/tab.m b/+labkit/+ui/+spec/tab.m new file mode 100644 index 0000000..b8580de --- /dev/null +++ b/+labkit/+ui/+spec/tab.m @@ -0,0 +1,22 @@ +function spec = tab(id, titleText, children, varargin) +%TAB Create a LabKit control-tab spec. +% +% App-facing contract: +% spec = labkit.ui.spec.tab(id, title, children, opts...) +% +% Inputs: +% id - globally unique tab id. +% titleText - tab title shown in the control pane. +% children - cell row vector of section specs. +% opts - app-neutral tab options. +% +% Output: +% spec - scalar data-only UI spec struct. + + if nargin < 3 + children = {}; + end + props = optionStruct(varargin); + props.title = char(string(titleText)); + spec = makeSpec('tab', id, props, children, struct()); +end diff --git a/+labkit/+ui/+spec/workspace.m b/+labkit/+ui/+spec/workspace.m new file mode 100644 index 0000000..0dbbef1 --- /dev/null +++ b/+labkit/+ui/+spec/workspace.m @@ -0,0 +1,22 @@ +function spec = workspace(id, titleText, children, varargin) +%WORKSPACE Create a right-side LabKit workbench workspace spec. +% +% App-facing contract: +% spec = labkit.ui.spec.workspace(id, title, children, opts...) +% +% Inputs: +% id - globally unique workspace id. +% titleText - workspace panel title. +% children - cell row vector of workspace child specs, usually previewArea. +% opts - app-neutral layout options such as row heights. +% +% Output: +% spec - scalar data-only UI spec struct. + + if nargin < 3 + children = {}; + end + props = optionStruct(varargin); + props.title = char(string(titleText)); + spec = makeSpec('workspace', id, props, children, struct()); +end diff --git a/+labkit/+ui/+tool/anchorEditor.m b/+labkit/+ui/+tool/anchorEditor.m index 125302a..df09636 100644 --- a/+labkit/+ui/+tool/anchorEditor.m +++ b/+labkit/+ui/+tool/anchorEditor.m @@ -206,7 +206,7 @@ function deleteEditor() if ~isempty(state.anchorLine) && isvalid(state.anchorLine) delete(state.anchorLine); end - labkit.ui.view.draw(state.ax, 'popout'); + enableAxesPopout(state.ax); end function onAxesClicked(~, ~) @@ -317,7 +317,7 @@ function ensureGraphics() created = true; end if created - labkit.ui.view.draw(state.ax, 'popout'); + enableAxesPopout(state.ax); end end diff --git a/+labkit/+ui/+tool/createRuntime.m b/+labkit/+ui/+tool/createRuntime.m index 4ddec77..6012e64 100644 --- a/+labkit/+ui/+tool/createRuntime.m +++ b/+labkit/+ui/+tool/createRuntime.m @@ -122,7 +122,7 @@ function setTraceCallback(fcn) function installDefaultCallbacks() if isValidHandle(state.ax) - labkit.ui.view.draw(state.ax, 'popout'); + enableAxesPopout(state.ax); end if ~isValidHandle(state.fig) || ~isempty(state.activeScrollToken) return; diff --git a/+labkit/+ui/+tool/private/enableAxesPopout.m b/+labkit/+ui/+tool/private/enableAxesPopout.m new file mode 100644 index 0000000..890c412 --- /dev/null +++ b/+labkit/+ui/+tool/private/enableAxesPopout.m @@ -0,0 +1,71 @@ +% Private UI app helper. Expected caller: buildWorkspace preview-area setup. +% Input is a UI axes. Output mutates the axes and children in place so the +% standard LabKit popout action remains available without a public axes helper. +function enableAxesPopout(ax) + if isempty(ax) || ~isvalid(ax) + return; + end + menu = ax.ContextMenu; + if isappdata(ax, 'labkitAxesPopoutEnabled') && ... + getappdata(ax, 'labkitAxesPopoutEnabled') && ... + ~isempty(menu) && isvalid(menu) && ... + ~isempty(findall(menu, 'Type', 'uimenu', 'Tag', 'labkitAxesPopoutMenu')) + attachMenuToAxesChildren(ax, menu); + return; + end + + fig = ancestor(ax, 'figure'); + if isempty(fig) + return; + end + + if isempty(menu) || ~isvalid(menu) + menu = uicontextmenu(fig); + ax.ContextMenu = menu; + end + + existing = findall(menu, 'Type', 'uimenu', 'Tag', 'labkitAxesPopoutMenu'); + if isempty(existing) + uimenu(menu, ... + 'Text', 'Open axes in new figure', ... + 'Tag', 'labkitAxesPopoutMenu', ... + 'MenuSelectedFcn', @(~,~) popoutAxes(ax)); + end + attachMenuToAxesChildren(ax, menu); + installChildrenListener(ax, menu); + setappdata(ax, 'labkitAxesPopoutEnabled', true); +end + +function installChildrenListener(ax, menu) + if isappdata(ax, 'labkitAxesPopoutChildrenListener') + listener = getappdata(ax, 'labkitAxesPopoutChildrenListener'); + if ~isempty(listener) + return; + end + end + try + listener = addlistener(ax, 'Children', 'PostSet', ... + @(~,~) attachMenuToAxesChildren(ax, menu)); + setappdata(ax, 'labkitAxesPopoutChildrenListener', listener); + catch + end +end + +function attachMenuToAxesChildren(ax, menu) + if isempty(ax) || ~isvalid(ax) || isempty(menu) || ~isvalid(menu) + return; + end + children = ax.Children; + for k = 1:numel(children) + child = children(k); + if ~isvalid(child) || ~isprop(child, 'ContextMenu') + continue; + end + if isempty(child.ContextMenu) || ~isvalid(child.ContextMenu) + try + child.ContextMenu = menu; + catch + end + end + end +end diff --git a/+labkit/+ui/+tool/private/popoutAxes.m b/+labkit/+ui/+tool/private/popoutAxes.m new file mode 100644 index 0000000..7dd17d1 --- /dev/null +++ b/+labkit/+ui/+tool/private/popoutAxes.m @@ -0,0 +1,120 @@ +% Private UI app helper. Expected caller: enableAxesPopout. Input is a source +% UI axes. Output is a standalone MATLAB figure containing copied axes content. +function newFig = popoutAxes(srcAx) + if isempty(srcAx) || ~isvalid(srcAx) + error('labkit:ui:InvalidAxes', 'Source axes is not valid.'); + end + + titleText = axisLabelText(srcAx.Title); + if strlength(titleText) == 0 + titleText = "LabKit Plot"; + end + + newFig = figure('Name', char(titleText), 'Color', 'w'); + dstAx = axes('Parent', newFig); + copyAxesState(srcAx, dstAx); + + children = flipud(srcAx.Children(:)); + if ~isempty(children) + copyobj(children, dstAx); + end + applyAxesState(srcAx, dstAx); +end + +function copyAxesState(srcAx, dstAx) + props = {'XScale','YScale','ZScale','XDir','YDir','ZDir', ... + 'XLim','YLim','ZLim','CLim', ... + 'View','Box','XGrid','YGrid','ZGrid','Color','XColor','YColor','ZColor', ... + 'LineWidth','FontName','FontSize','FontWeight','FontAngle'}; + for k = 1:numel(props) + try + dstAx.(props{k}) = srcAx.(props{k}); + catch + end + end + try + colormap(dstAx, colormap(srcAx)); + catch + end +end + +function applyAxesState(srcAx, dstAx) + title(dstAx, axisLabelText(srcAx.Title)); + xlabel(dstAx, axisLabelText(srcAx.XLabel)); + ylabel(dstAx, axisLabelText(srcAx.YLabel)); + zlabel(dstAx, axisLabelText(srcAx.ZLabel)); + + try + dstAx.XLimMode = srcAx.XLimMode; + dstAx.YLimMode = srcAx.YLimMode; + dstAx.ZLimMode = srcAx.ZLimMode; + dstAx.CLimMode = srcAx.CLimMode; + catch + end + applyAspectRatio(srcAx, dstAx); + addLegendIfNeeded(dstAx); +end + +function applyAspectRatio(srcAx, dstAx) + if hasImageContent(srcAx) + lockImageAspectRatio(srcAx, dstAx); + else + unlockAspectRatio(dstAx); + end +end + +function unlockAspectRatio(ax) + try + ax.DataAspectRatioMode = 'auto'; + ax.PlotBoxAspectRatioMode = 'auto'; + ax.ActivePositionProperty = 'outerposition'; + catch + end +end + +function lockImageAspectRatio(srcAx, dstAx) + try + dstAx.DataAspectRatio = srcAx.DataAspectRatio; + dstAx.DataAspectRatioMode = 'manual'; + dstAx.PlotBoxAspectRatioMode = 'auto'; + dstAx.ActivePositionProperty = 'outerposition'; + catch + axis(dstAx, 'image'); + end +end + +function tf = hasImageContent(ax) + children = ax.Children; + tf = false; + for k = 1:numel(children) + if isgraphics(children(k), 'image') + tf = true; + return; + end + end +end + +function text = axisLabelText(labelHandle) + value = labelHandle.String; + if iscell(value) + text = string(strjoin(value, newline)); + else + text = string(value); + end +end + +function addLegendIfNeeded(ax) + children = ax.Children; + names = strings(0, 1); + for k = 1:numel(children) + if isprop(children(k), 'DisplayName') + name = string(children(k).DisplayName); + if strlength(name) > 0 && ~startsWith(name, "_") + names(end + 1, 1) = name; + end + end + end + if ~isempty(names) + legend(ax, 'show', 'Interpreter', 'none'); + end +end diff --git a/+labkit/+ui/+tool/private/scaleBarPanel.m b/+labkit/+ui/+tool/private/scaleBarPanel.m index d863a0a..223f9a8 100644 --- a/+labkit/+ui/+tool/private/scaleBarPanel.m +++ b/+labkit/+ui/+tool/private/scaleBarPanel.m @@ -65,11 +65,15 @@ defaultScaleBarLength = optionValue(opts, 'defaultScaleBarLength', 1); panelTitle = char(string(optionValue(opts, 'title', 'Scale Bar'))); - panelUi = labkit.ui.view.section(parent, panelTitle, row, [10 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', ... - 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - grid = panelUi.grid; + panel = uipanel(parent, 'Title', panelTitle); + panel.Layout.Row = row; + panel.Layout.Column = 1; + grid = uigridlayout(panel, [10 2]); + grid.RowHeight = {'fit', 'fit', 'fit', 'fit', 'fit', ... + 'fit', 'fit', 'fit', 'fit', 'fit'}; + grid.ColumnWidth = {145, '1x'}; + grid.Padding = [8 8 8 8]; + panelUi = struct('panel', panel, 'grid', grid); btnMeasureReference = uibutton(grid, ... 'Text', 'Measure reference pixels', ... diff --git a/+labkit/+ui/+view/appendLog.m b/+labkit/+ui/+view/appendLog.m new file mode 100644 index 0000000..c900a91 --- /dev/null +++ b/+labkit/+ui/+view/appendLog.m @@ -0,0 +1,47 @@ +function appendLog(ui, idOrMessage, maybeMessage) +%APPENDLOG Append a line to a UI 2.0 log panel. +% +% App-facing contract: +% labkit.ui.view.appendLog(ui, message) +% labkit.ui.view.appendLog(ui, id, message) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - optional logPanel id. If omitted, the first log panel is used. +% message - text appended to the log panel. +% +% Output: +% None. + + if nargin < 3 + id = firstControlOfKind(ui, 'logPanel'); + message = idOrMessage; + else + id = idOrMessage; + message = maybeMessage; + end + + control = resolveControl(ui, id); + if ~isfield(control, 'textArea') + error('labkit:ui:view:InvalidLogPanel', ... + 'Control "%s" is not a log panel.', control.id); + end + timestamp = datestr(now, 'HH:MM:SS'); + old = control.textArea.Value; + old{end + 1} = sprintf('[%s] %s', timestamp, char(message)); + control.textArea.Value = old; + drawnow limitrate +end + +function id = firstControlOfKind(ui, kind) + names = fieldnames(ui.controls); + for k = 1:numel(names) + control = ui.controls.(names{k}); + if isfield(control, 'kind') && strcmp(control.kind, kind) + id = names{k}; + return; + end + end + error('labkit:ui:view:UnknownControl', ... + 'No UI control of kind "%s" exists.', kind); +end diff --git a/+labkit/+ui/+view/axes.m b/+labkit/+ui/+view/axes.m deleted file mode 100644 index 46299e3..0000000 --- a/+labkit/+ui/+view/axes.m +++ /dev/null @@ -1,18 +0,0 @@ -function ax = axes(parent, row, titleText, xLabelText, yLabelText) -%CREATEAXES Create an axes and apply its initial layout and labels. -% -% Inputs: -% parent - parent grid. -% row - logical parent row, mapped through layoutRow. -% titleText, xLabelText, yLabelText - initial axes labels. -% -% Output: -% ax - UI axes with standard popout context action enabled. - - ax = uiaxes(parent); - ax.Layout.Row = layoutRow(parent, row); - title(ax, titleText); - xlabel(ax, xLabelText); - ylabel(ax, yLabelText); - enablePopout(ax); -end diff --git a/+labkit/+ui/+view/clearAxes.m b/+labkit/+ui/+view/clearAxes.m new file mode 100644 index 0000000..c882ba9 --- /dev/null +++ b/+labkit/+ui/+view/clearAxes.m @@ -0,0 +1,26 @@ +function clearAxes(ui, id, axisId) +%CLEARAXES Clear a UI 2.0 previewArea axes. +% +% App-facing contract: +% labkit.ui.view.clearAxes(ui, id, axisId) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - semantic id for a previewArea. +% axisId - optional named axes id. +% +% Output: +% None. + + if nargin < 3 + axisId = ""; + end + control = resolveControl(ui, id); + ax = controlAxes(control, axisId); + if ~isempty(ax.Children) + delete(ax.Children); + end + hold(ax, 'off'); + ax.XLimMode = 'auto'; + ax.YLimMode = 'auto'; +end diff --git a/+labkit/+ui/+view/draw.m b/+labkit/+ui/+view/draw.m deleted file mode 100644 index 91149be..0000000 --- a/+labkit/+ui/+view/draw.m +++ /dev/null @@ -1,55 +0,0 @@ -function varargout = draw(ax, action, varargin) -%DRAW Apply an app-neutral rendering action to axes. -% -% App-facing contract: -% labkit.ui.view.draw(ax, "reset", titleText, resetScaleAndTicks) -% hImage = labkit.ui.view.draw(ax, "image", imageData, titleText, opts) -% labkit.ui.view.draw(ax, "clear") -% labkit.ui.view.draw(ax, "popout") -% -% Inputs: -% ax - target axes. -% action - "reset", "image", "clear", or "popout". -% varargin - action-specific payload described above. -% -% Outputs: -% image returns the image graphics object. reset, clear, and popout mutate -% axes in place and return [] when captured. - - switch normalizeAction(action) - case 'reset' - titleText = positional(varargin, 1, ''); - resetScaleAndTicks = positional(varargin, 2, false); - resetAxes(ax, titleText, resetScaleAndTicks); - out = []; - case 'image' - imageData = positional(varargin, 1, []); - titleText = positional(varargin, 2, ''); - opts = positional(varargin, 3, struct()); - out = showImage(ax, imageData, titleText, opts); - case 'clear' - clearAxes(ax); - out = []; - case 'popout' - enablePopout(ax); - out = []; - otherwise - error('labkit_ui:draw:UnknownAction', ... - 'Unknown LabKit view draw action "%s".', char(action)); - end - - if nargout > 0 - varargout{1} = out; - end -end - -function action = normalizeAction(action) - action = lower(regexprep(char(string(action)), '[^a-zA-Z0-9]', '')); -end - -function value = positional(args, index, defaultValue) - value = defaultValue; - if numel(args) >= index && ~isempty(args{index}) - value = args{index}; - end -end diff --git a/+labkit/+ui/+view/drawImage.m b/+labkit/+ui/+view/drawImage.m new file mode 100644 index 0000000..aba1aaa --- /dev/null +++ b/+labkit/+ui/+view/drawImage.m @@ -0,0 +1,49 @@ +function imageHandle = drawImage(ui, id, imageData, varargin) +%DRAWIMAGE Draw image data into a UI 2.0 previewArea axes. +% +% App-facing contract: +% h = labkit.ui.view.drawImage(ui, id, imageData, "title", titleText, ... +% "axis", axisId, "options", opts) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - semantic id for a previewArea. +% imageData - image matrix passed to the axes renderer. +% title - optional axes title. +% axis - optional named axes id. +% options - optional struct passed to the existing image renderer. +% +% Output: +% imageHandle - graphics image object returned by the renderer. + + opts = parseOptions(varargin); + control = resolveControl(ui, id); + ax = controlAxes(control, optionValue(opts, 'axis', "")); + imageHandle = showImage(ax, imageData, ... + optionValue(opts, 'title', ''), optionValue(opts, 'options', struct())); +end + +function opts = parseOptions(args) + opts = struct(); + if isempty(args) + return; + end + if numel(args) == 1 && (ischar(args{1}) || isstring(args{1})) + opts.title = char(string(args{1})); + return; + end + if mod(numel(args), 2) ~= 0 + error('labkit:ui:view:InvalidOptions', ... + 'drawImage options must be name/value pairs.'); + end + for k = 1:2:numel(args) + opts.(char(string(args{k}))) = args{k + 1}; + end +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+ui/+view/form.m b/+labkit/+ui/+view/form.m deleted file mode 100644 index 4c3e4ac..0000000 --- a/+labkit/+ui/+view/form.m +++ /dev/null @@ -1,265 +0,0 @@ -function varargout = form(parent, spec) -%FORM Create LabKit form controls from a unified control spec. -% -% Usage: -% [lbl, spinner] = labkit.ui.view.form(parent, struct( ... -% 'kind', 'spinner', 'label', 'Samples:', ... -% 'value', 10, 'limits', [1 Inf], 'step', 1, ... -% 'callback', @onChanged)); -% -% ui = labkit.ui.view.form(parent, struct( ... -% 'title', 'Settings', 'row', 2, 'layout', [2 2], ... -% 'controls', [struct('id','mode','kind','dropdown', ...)])); -% -% Inputs: -% parent - parent grid or shell tab grid. -% spec - scalar struct. Single-control specs use kind/label fields. Section -% specs use title, row, layout, and controls fields. -% -% Control fields: -% id - optional valid field name for returned ui.controls.(id). -% kind - "spinner", "dropdown", "edit", "readonly", "info", "button", -% or "checkbox". -% label - label text for labeled controls. -% style - edit-field style, default "text". -% value, items, limits, step, valueDisplayFormat, enabled, callback - -% optional common values. -% text - button or checkbox label text. -% row, column - optional layout location. -% -% Outputs: -% Single-control call returns [labelHandle, controlHandle] for labeled -% controls or the control handle for button/checkbox/readonly without a -% label. Section calls return a struct with panel, grid, controls, -% labels, setValue, and getValue. -% -% setValue(id, value, reason) no-ops when the value is unchanged and -% suppresses app-facing semantic callbacks for "internal" updates. - - if nargin < 2 - error('labkit:ui:view:InvalidFormSpec', ... - 'form requires a scalar struct spec.'); - end - - if ~isstruct(spec) || ~isscalar(spec) - error('labkit:ui:view:InvalidFormSpec', ... - 'form requires a scalar struct spec.'); - end - - if isfield(spec, 'controls') - ui = createFormSection(parent, spec); - varargout = {ui}; - return; - end - - [labelHandle, controlHandle] = createOne(parent, spec); - if nargout <= 1 - varargout = {controlHandle}; - elseif isfield(spec, 'kind') && strcmpi(char(string(spec.kind)), 'info') - varargout = {controlHandle, labelHandle}; - else - varargout = {labelHandle, controlHandle}; - end -end - -function ui = createFormSection(parent, spec) - layout = optionValue(spec, 'layout', [numel(spec.controls) 2]); - ui = labkit.ui.view.section(parent, ... - optionValue(spec, 'title', ''), ... - optionValue(spec, 'row', []), ... - layout, optionValue(spec, 'sectionOptions', struct())); - ui.controls = struct(); - ui.labels = struct(); - ui.setValue = @setValue; - ui.getValue = @getValue; - - controls = spec.controls; - for k = 1:numel(controls) - controlSpec = controls(k); - if ~isfield(controlSpec, 'row') - controlSpec.row = k; - end - [lbl, ctrl] = createOne(ui.grid, controlSpec); - if isfield(controlSpec, 'id') && strlength(string(controlSpec.id)) > 0 - id = matlab.lang.makeValidName(char(string(controlSpec.id))); - ui.controls.(id) = ctrl; - if ~isempty(lbl) - ui.labels.(id) = lbl; - end - end - end - - function setValue(id, value, reason) - if nargin < 3 - reason = "programmatic"; - end - name = matlab.lang.makeValidName(char(string(id))); - if ~isfield(ui.controls, name) - error('labkit:ui:view:UnknownControl', ... - 'Unknown form control "%s".', char(string(id))); - end - ctrl = ui.controls.(name); - if ~isprop(ctrl, 'Value') || valuesEqual(ctrl.Value, value) - return; - end - oldCallback = callbackProperty(ctrl); - cleanupObj = suppressCallback(ctrl, oldCallback, reason); - ctrl.Value = value; - end - - function value = getValue(id) - name = matlab.lang.makeValidName(char(string(id))); - if ~isfield(ui.controls, name) - error('labkit:ui:view:UnknownControl', ... - 'Unknown form control "%s".', char(string(id))); - end - ctrl = ui.controls.(name); - if isprop(ctrl, 'Value') - value = ctrl.Value; - else - value = []; - end - end -end - -function [lbl, ctrl] = createOne(parent, spec) - kind = lower(char(string(optionValue(spec, 'kind', 'edit')))); - labelText = char(string(optionValue(spec, 'label', ''))); - args = commonArgs(spec); - - switch kind - case 'spinner' - [lbl, ctrl] = createLabeledSpinner(parent, labelText, args{:}); - case 'dropdown' - [lbl, ctrl] = createLabeledDropdown(parent, labelText, args{:}); - case 'edit' - style = optionValue(spec, 'style', 'text'); - [lbl, ctrl] = createLabeledEditField(parent, labelText, style, args{:}); - case 'readonly' - lbl = []; - ctrl = createReadOnlyTextField(parent, args{:}); - case 'info' - row = optionValue(spec, 'row', []); - [ctrl, lbl] = createReadOnlyInfoRow(parent, row, labelText); - case 'button' - lbl = []; - ctrl = uibutton(parent, args{:}); - if isfield(spec, 'text') - ctrl.Text = spec.text; - elseif isfield(spec, 'label') - ctrl.Text = spec.label; - end - case 'checkbox' - lbl = []; - ctrl = uicheckbox(parent, args{:}); - if isfield(spec, 'text') - ctrl.Text = spec.text; - elseif isfield(spec, 'label') - ctrl.Text = spec.label; - end - otherwise - error('labkit:ui:view:UnknownControlKind', ... - 'Unsupported form control kind "%s".', kind); - end - - if isfield(spec, 'row') && ~isempty(spec.row) - placeHandle(lbl, spec.row, 1); - if strcmp(kind, 'button') || strcmp(kind, 'checkbox') || strcmp(kind, 'readonly') - placeHandle(ctrl, spec.row, optionValue(spec, 'column', [1 2])); - else - placeHandle(ctrl, spec.row, optionValue(spec, 'column', 2)); - end - end -end - -function args = commonArgs(spec) - args = {}; - if isfield(spec, 'items') - args = [args, {'Items', spec.items}]; - end - if isfield(spec, 'value') - args = [args, {'Value', spec.value}]; - end - if isfield(spec, 'limits') - args = [args, {'Limits', spec.limits}]; - end - if isfield(spec, 'step') - args = [args, {'Step', spec.step}]; - end - if isfield(spec, 'valueDisplayFormat') - args = [args, {'ValueDisplayFormat', spec.valueDisplayFormat}]; - end - if isfield(spec, 'enabled') - args = [args, {'Enable', onOff(spec.enabled)}]; - end - if isfield(spec, 'callback') - if any(strcmpi(char(string(optionValue(spec, 'kind', 'edit'))), ... - {'button'})) - args = [args, {'ButtonPushedFcn', spec.callback}]; - else - args = [args, {'ValueChangedFcn', spec.callback}]; - end - end -end - -function placeHandle(h, row, column) - if isempty(h) || ~isvalid(h) - return; - end - h.Layout.Row = row; - h.Layout.Column = column; -end - -function oldCallback = callbackProperty(ctrl) - oldCallback = struct('property', '', 'value', []); - for prop = {'ValueChangedFcn', 'ButtonPushedFcn'} - name = prop{1}; - if isprop(ctrl, name) - oldCallback.property = name; - oldCallback.value = ctrl.(name); - return; - end - end -end - -function cleanupObj = suppressCallback(ctrl, oldCallback, reason) - if strcmp(string(reason), "user") || isempty(oldCallback.property) - cleanupObj = onCleanup(@() []); - return; - end - ctrl.(oldCallback.property) = []; - cleanupObj = onCleanup(@() restoreCallback(ctrl, oldCallback)); -end - -function restoreCallback(ctrl, oldCallback) - if ~isempty(ctrl) && isvalid(ctrl) && isprop(ctrl, oldCallback.property) - ctrl.(oldCallback.property) = oldCallback.value; - end -end - -function tf = valuesEqual(a, b) - try - tf = isequaln(a, b); - catch - tf = false; - end -end - -function text = onOff(value) - if islogical(value) && isscalar(value) - if value - text = 'on'; - else - text = 'off'; - end - else - text = char(string(value)); - end -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - end -end diff --git a/+labkit/+ui/+view/getValue.m b/+labkit/+ui/+view/getValue.m new file mode 100644 index 0000000..327d17b --- /dev/null +++ b/+labkit/+ui/+view/getValue.m @@ -0,0 +1,25 @@ +function value = getValue(ui, id) +%GETVALUE Read a UI 2.0 control value through the semantic registry. +% +% App-facing contract: +% value = labkit.ui.view.getValue(ui, id) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - globally unique semantic control id. +% +% Output: +% value - current Value property from the control's primary value handle. + + control = resolveControl(ui, id); + if isfield(control, 'getValue') && isa(control.getValue, 'function_handle') + value = control.getValue(); + return; + end + handle = controlValueHandle(control); + if isprop(handle, 'Value') + value = handle.Value; + else + value = []; + end +end diff --git a/+labkit/+ui/+view/panel.m b/+labkit/+ui/+view/panel.m deleted file mode 100644 index fbc1b1c..0000000 --- a/+labkit/+ui/+view/panel.m +++ /dev/null @@ -1,115 +0,0 @@ -function ui = panel(parent, kind, varargin) -%PANEL Create a reusable view component group. -% -% App-facing contract: -% ui = labkit.ui.view.panel(parent, kind, ...) -% ui = labkit.ui.view.panel(parent, spec) -% -% Inputs: -% parent - parent container for the component group. -% kind - component kind: "files", "log", "text", or "table". -% spec - optional struct alternative with kind plus fields matching the -% positional arguments described below. -% -% Positional forms: -% panel(parent, "files", labels, callbacks, opts) -% panel(parent, "log", row, initialValue) -% panel(parent, "text", title, row, lines, opts) -% panel(parent, "table", title, row, columnNames, initialData) -% -% Output: -% ui - struct of MATLAB component handles owned by the created component. - - if isstruct(kind) - ui = panelFromSpec(parent, kind); - return; - end - - switch normalizeKind(kind) - case 'files' - labels = positional(varargin, 1, struct()); - callbacks = positional(varargin, 2, struct()); - opts = positional(varargin, 3, struct()); - ui = fileSelectionPanel(parent, labels, callbacks, opts); - case 'log' - row = positional(varargin, 1, 1); - initialValue = positional(varargin, 2, {'GUI started.'}); - ui = logPanel(parent, row, initialValue); - case 'text' - titleText = positional(varargin, 1, ''); - row = positional(varargin, 2, 1); - lines = positional(varargin, 3, {}); - opts = positional(varargin, 4, struct()); - ui = textPanel(parent, titleText, row, lines, opts); - case 'table' - titleText = positional(varargin, 1, ''); - row = positional(varargin, 2, 1); - columnNames = positional(varargin, 3, {}); - initialData = positional(varargin, 4, cell(0, numel(columnNames))); - ui = resultTable(parent, titleText, row, columnNames, initialData); - otherwise - error('labkit_ui:panel:UnknownKind', ... - 'Unknown LabKit view panel kind "%s".', char(kind)); - end -end - -function ui = panelFromSpec(parent, spec) - kind = requireField(spec, 'kind'); - switch normalizeKind(kind) - case 'files' - labels = fieldOr(spec, 'labels', struct()); - callbacks = fieldOr(spec, 'callbacks', struct()); - opts = mergeFieldOptions(fieldOr(spec, 'options', struct()), spec, {'row'}); - ui = fileSelectionPanel(parent, labels, callbacks, opts); - case 'log' - ui = logPanel(parent, fieldOr(spec, 'row', 1), ... - fieldOr(spec, 'initialValue', {'GUI started.'})); - case 'text' - ui = textPanel(parent, fieldOr(spec, 'title', ''), ... - fieldOr(spec, 'row', 1), fieldOr(spec, 'lines', {}), ... - fieldOr(spec, 'options', struct())); - case 'table' - columnNames = fieldOr(spec, 'columnNames', {}); - ui = resultTable(parent, fieldOr(spec, 'title', ''), ... - fieldOr(spec, 'row', 1), columnNames, ... - fieldOr(spec, 'initialData', cell(0, numel(columnNames)))); - otherwise - error('labkit_ui:panel:UnknownKind', ... - 'Unknown LabKit view panel kind "%s".', char(kind)); - end -end - -function key = normalizeKind(kind) - key = lower(regexprep(char(string(kind)), '[^a-zA-Z0-9]', '')); -end - -function value = positional(args, index, defaultValue) - value = defaultValue; - if numel(args) >= index && ~isempty(args{index}) - value = args{index}; - end -end - -function value = fieldOr(spec, name, defaultValue) - value = defaultValue; - if isfield(spec, name) && ~isempty(spec.(name)) - value = spec.(name); - end -end - -function value = requireField(spec, name) - if ~isfield(spec, name) || isempty(spec.(name)) - error('labkit_ui:panel:MissingField', ... - 'labkit.ui.view.panel spec requires field "%s".', name); - end - value = spec.(name); -end - -function opts = mergeFieldOptions(opts, spec, names) - for k = 1:numel(names) - name = names{k}; - if isfield(spec, name) && ~isempty(spec.(name)) - opts.(name) = spec.(name); - end - end -end diff --git a/+labkit/+ui/+view/place.m b/+labkit/+ui/+view/place.m deleted file mode 100644 index 25b261b..0000000 --- a/+labkit/+ui/+view/place.m +++ /dev/null @@ -1,21 +0,0 @@ -function place(component, parent, row, column) -%PLACE Place a component on a LabKit logical shell row. -% -% Inputs: -% component - MATLAB UI component with a Layout property. -% parent - parent grid. Shell tab grids may contain a private logical row -% map inserted by labkit.ui.app.createShell. -% row - logical row in the parent grid. -% column - optional Layout.Column value. -% -% Output: -% Mutates component.Layout.Row and optionally component.Layout.Column. - - if isempty(component) || ~isvalid(component) - return; - end - component.Layout.Row = layoutRow(parent, row); - if nargin >= 4 && ~isempty(column) - component.Layout.Column = column; - end -end diff --git a/+labkit/+ui/+view/private/controlAxes.m b/+labkit/+ui/+view/private/controlAxes.m new file mode 100644 index 0000000..3e843ce --- /dev/null +++ b/+labkit/+ui/+view/private/controlAxes.m @@ -0,0 +1,28 @@ +% Private UI view helper. Expected caller: named labkit.ui.view axes helpers. +% Returns the requested axes from a previewArea or axes-like UI 2.0 adapter. +function ax = controlAxes(control, axisId) + if nargin < 2 + axisId = ""; + end + + if strlength(string(axisId)) > 0 && isfield(control, 'axesById') + name = char(string(axisId)); + if isfield(control.axesById, name) + ax = control.axesById.(name); + return; + end + error('labkit:ui:view:UnknownAxes', ... + 'Control "%s" does not have axes "%s".', control.id, name); + end + + if isfield(control, 'primaryAxes') && isgraphics(control.primaryAxes) + ax = control.primaryAxes; + return; + end + if isfield(control, 'axes') && isgraphics(control.axes) + ax = control.axes(1); + return; + end + error('labkit:ui:view:NoAxes', ... + 'Control "%s" does not expose axes.', control.id); +end diff --git a/+labkit/+ui/+view/private/controlHandles.m b/+labkit/+ui/+view/private/controlHandles.m new file mode 100644 index 0000000..2e325a2 --- /dev/null +++ b/+labkit/+ui/+view/private/controlHandles.m @@ -0,0 +1,37 @@ +% Private UI view helper. Expected caller: named labkit.ui.view helpers. +% Collects MATLAB graphics handles from a UI 2.0 control adapter so common +% state such as Enable can be applied across compound controls. +function handles = controlHandles(control) + handles = {}; + handles = appendHandles(handles, control); +end + +function handles = appendHandles(handles, value) + if isempty(value) + return; + end + if isgraphics(value) + for k = 1:numel(value) + if isvalid(value(k)) + handles{end+1} = value(k); + end + end + return; + end + if iscell(value) + for k = 1:numel(value) + handles = appendHandles(handles, value{k}); + end + return; + end + if isstruct(value) + fields = fieldnames(value); + for k = 1:numel(fields) + field = fields{k}; + if ismember(field, {'props', 'event'}) + continue; + end + handles = appendHandles(handles, value.(field)); + end + end +end diff --git a/+labkit/+ui/+view/private/controlValueHandle.m b/+labkit/+ui/+view/private/controlValueHandle.m new file mode 100644 index 0000000..b91868e --- /dev/null +++ b/+labkit/+ui/+view/private/controlValueHandle.m @@ -0,0 +1,22 @@ +% Private UI view helper. Expected caller: named labkit.ui.view helpers. +% Returns the primary value-bearing MATLAB handle from a UI 2.0 control +% adapter. The adapter shape is internal and may vary by spec family. +function handle = controlValueHandle(control) + if isfield(control, 'valueHandle') && isvalidHandle(control.valueHandle) + handle = control.valueHandle; + return; + end + for name = {'handle', 'listbox', 'textArea', 'table', 'button'} + field = name{1}; + if isfield(control, field) && isvalidHandle(control.(field)) + handle = control.(field); + return; + end + end + error('labkit:ui:view:NoValueHandle', ... + 'Control "%s" does not expose a value handle.', control.id); +end + +function tf = isvalidHandle(value) + tf = ~isempty(value) && isgraphics(value) && isvalid(value); +end diff --git a/+labkit/+ui/+view/private/createLabeledDropdown.m b/+labkit/+ui/+view/private/createLabeledDropdown.m deleted file mode 100644 index e7b832b..0000000 --- a/+labkit/+ui/+view/private/createLabeledDropdown.m +++ /dev/null @@ -1,21 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function [lbl, dd] = createLabeledDropdown(parent, labelText, varargin) -%CREATELABELEDDROPDOWN Create a right-aligned label followed by a dropdown. -% -% Inputs: -% parent - parent grid. -% labelText - visible label. -% varargin - name/value arguments forwarded to uidropdown. -% -% Output: -% lbl - uilabel handle. -% dd - uidropdown handle. - - lbl = uilabel(parent, ... - 'Text', labelText, ... - 'HorizontalAlignment', 'right'); - dd = uidropdown(parent, varargin{:}); -end diff --git a/+labkit/+ui/+view/private/createLabeledEditField.m b/+labkit/+ui/+view/private/createLabeledEditField.m deleted file mode 100644 index 554e227..0000000 --- a/+labkit/+ui/+view/private/createLabeledEditField.m +++ /dev/null @@ -1,22 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function [lbl, field] = createLabeledEditField(parent, labelText, style, varargin) -%CREATELABELEDEDITFIELD Create a right-aligned label followed by an edit field. -% -% Inputs: -% parent - parent grid. -% labelText - visible label. -% style - uieditfield style, for example "text" or "numeric". -% varargin - name/value arguments forwarded to uieditfield. -% -% Output: -% lbl - uilabel handle. -% field - uieditfield handle. - - lbl = uilabel(parent, ... - 'Text', labelText, ... - 'HorizontalAlignment', 'right'); - field = uieditfield(parent, style, varargin{:}); -end diff --git a/+labkit/+ui/+view/private/createLabeledSpinner.m b/+labkit/+ui/+view/private/createLabeledSpinner.m deleted file mode 100644 index 7fe71aa..0000000 --- a/+labkit/+ui/+view/private/createLabeledSpinner.m +++ /dev/null @@ -1,21 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function [lbl, spinner] = createLabeledSpinner(parent, labelText, varargin) -%CREATELABELEDSPINNER Create a right-aligned label followed by a spinner. -% -% Inputs: -% parent - parent grid. -% labelText - visible label. -% varargin - name/value arguments forwarded to uispinner. -% -% Output: -% lbl - uilabel handle. -% spinner - uispinner handle. - - lbl = uilabel(parent, ... - 'Text', labelText, ... - 'HorizontalAlignment', 'right'); - spinner = uispinner(parent, varargin{:}); -end diff --git a/+labkit/+ui/+view/private/createReadOnlyInfoRow.m b/+labkit/+ui/+view/private/createReadOnlyInfoRow.m deleted file mode 100644 index a2656f1..0000000 --- a/+labkit/+ui/+view/private/createReadOnlyInfoRow.m +++ /dev/null @@ -1,24 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function [field, lbl] = createReadOnlyInfoRow(parent, row, labelText) -%CREATEREADONLYINFOROW Create a labeled read-only text field row. -% -% Inputs: -% parent - parent grid. -% row - row inside parent. -% labelText - visible label. -% -% Output: -% field - read-only text field initialized to "-". -% lbl - label handle. - - [lbl, field] = createLabeledEditField(parent, labelText, 'text', ... - 'Editable', 'off', ... - 'Value', '-'); - lbl.Layout.Row = row; - lbl.Layout.Column = 1; - field.Layout.Row = row; - field.Layout.Column = 2; -end diff --git a/+labkit/+ui/+view/private/createReadOnlyTextField.m b/+labkit/+ui/+view/private/createReadOnlyTextField.m deleted file mode 100644 index 5f515f3..0000000 --- a/+labkit/+ui/+view/private/createReadOnlyTextField.m +++ /dev/null @@ -1,18 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function field = createReadOnlyTextField(parent, varargin) -%CREATEREADONLYTEXTFIELD Create a read-only single-line text field. -% -% Inputs: -% parent - parent grid. -% varargin - name/value arguments forwarded to uieditfield. -% -% Output: -% field - read-only text uieditfield handle. - - field = uieditfield(parent, 'text', ... - 'Editable', 'off', ... - varargin{:}); -end diff --git a/+labkit/+ui/+view/private/fileSelectionPanel.m b/+labkit/+ui/+view/private/fileSelectionPanel.m deleted file mode 100644 index 9e4cc55..0000000 --- a/+labkit/+ui/+view/private/fileSelectionPanel.m +++ /dev/null @@ -1,142 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function ui = fileSelectionPanel(parent, labels, callbacks, opts) -%CREATEFILESELECTIONPANEL Create a shared file-action panel with a listbox. -% -% Usage: -% labels = struct('panelTitle','Files','openFiles','Open file(s)'); -% callbacks = struct('onOpenFiles',@onOpen,'onExport',@onExport); -% ui = fileSelectionPanel(parent, labels, callbacks); -% -% Inputs: -% parent - parent grid. -% labels - optional struct of visible text labels. -% callbacks - optional struct of button/listbox callbacks. -% opts - optional struct. -% -% Label fields: -% panelTitle, openFiles, openFolder, removeSelected, clearAll, export, -% loadedText. -% -% Callback fields: -% onOpenFiles, onOpenFolder, onRemoveSelected, onClearAll, onExport, -% onSelectFile. -% -% Options: -% showRemoveSelected - logical, default true when onRemoveSelected exists. -% multiselect - "off" (default) or "on" for the listbox. -% row - logical parent row, default 1. -% -% Output: -% ui - struct with panel, grid, buttons, listbox, and loadedText fields. - - if nargin < 4 - opts = struct(); - end - - showRemoveSelected = optionValue(opts, 'showRemoveSelected', ... - isfield(callbacks, 'onRemoveSelected')); - multiselect = optionValue(opts, 'multiselect', 'off'); - row = optionValue(opts, 'row', 1); - - gridOpts = struct( ... - 'rowHeight', {{'fit', '1x', 'fit'}}, ... - 'columnWidth', {{'1x'}}, ... - 'columnSpacing', 0); - ui = labkit.ui.view.section( ... - parent, labelValue(labels, 'panelTitle', 'Files'), row, [3 1], gridOpts); - - if showRemoveSelected - ui.buttonGrid = uigridlayout(ui.grid, [3 2]); - ui.buttonGrid.RowHeight = {'fit', 'fit', 'fit'}; - else - ui.buttonGrid = uigridlayout(ui.grid, [2 2]); - ui.buttonGrid.RowHeight = {'fit', 'fit'}; - end - ui.buttonGrid.Layout.Row = 1; - ui.buttonGrid.Layout.Column = 1; - ui.buttonGrid.ColumnWidth = {'1x', '1x'}; - ui.buttonGrid.RowSpacing = 8; - ui.buttonGrid.ColumnSpacing = 8; - ui.buttonGrid.Padding = [0 0 0 0]; - - ui.openButton = uibutton(ui.buttonGrid, ... - 'Text', labelValue(labels, 'openFiles', 'Open file(s)'), ... - 'ButtonPushedFcn', callbackValue(callbacks, 'onOpenFiles')); - ui.openButton.Layout.Row = 1; - ui.openButton.Layout.Column = 1; - - ui.openFolderButton = uibutton(ui.buttonGrid, ... - 'Text', labelValue(labels, 'openFolder', 'Open folder'), ... - 'ButtonPushedFcn', callbackValue(callbacks, 'onOpenFolder')); - ui.openFolderButton.Layout.Row = 1; - ui.openFolderButton.Layout.Column = 2; - - if showRemoveSelected - ui.removeButton = uibutton(ui.buttonGrid, ... - 'Text', labelValue(labels, 'removeSelected', 'Remove selected'), ... - 'ButtonPushedFcn', callbackValue(callbacks, 'onRemoveSelected')); - ui.removeButton.Layout.Row = 2; - ui.removeButton.Layout.Column = 1; - - ui.clearButton = uibutton(ui.buttonGrid, ... - 'Text', labelValue(labels, 'clearAll', 'Clear all'), ... - 'ButtonPushedFcn', callbackValue(callbacks, 'onClearAll')); - ui.clearButton.Layout.Row = 2; - ui.clearButton.Layout.Column = 2; - - ui.exportButton = uibutton(ui.buttonGrid, ... - 'Text', labelValue(labels, 'export', 'Export'), ... - 'ButtonPushedFcn', callbackValue(callbacks, 'onExport')); - ui.exportButton.Layout.Row = 3; - ui.exportButton.Layout.Column = [1 2]; - else - ui.clearButton = uibutton(ui.buttonGrid, ... - 'Text', labelValue(labels, 'clearAll', 'Clear all'), ... - 'ButtonPushedFcn', callbackValue(callbacks, 'onClearAll')); - ui.clearButton.Layout.Row = 2; - ui.clearButton.Layout.Column = 1; - - ui.exportButton = uibutton(ui.buttonGrid, ... - 'Text', labelValue(labels, 'export', 'Export'), ... - 'ButtonPushedFcn', callbackValue(callbacks, 'onExport')); - ui.exportButton.Layout.Row = 2; - ui.exportButton.Layout.Column = 2; - end - - ui.listbox = uilistbox(ui.grid, ... - 'Items', {}, ... - 'Multiselect', multiselect, ... - 'ValueChangedFcn', callbackValue(callbacks, 'onSelectFile')); - ui.listbox.Layout.Row = 2; - ui.listbox.Layout.Column = 1; - - ui.loadedText = uieditfield(ui.grid, 'text', ... - 'Editable', 'off', ... - 'Value', labelValue(labels, 'loadedText', 'No files loaded')); - ui.loadedText.Layout.Row = 3; - ui.loadedText.Layout.Column = 1; -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isfield(opts, name) - value = opts.(name); - end -end - -function value = labelValue(labels, name, defaultValue) - value = defaultValue; - if isfield(labels, name) - value = labels.(name); - end -end - -function cb = callbackValue(callbacks, name) - cb = []; - if isfield(callbacks, name) - cb = callbacks.(name); - end -end diff --git a/+labkit/+ui/+view/private/layoutRow.m b/+labkit/+ui/+view/private/layoutRow.m deleted file mode 100644 index 4c462be..0000000 --- a/+labkit/+ui/+view/private/layoutRow.m +++ /dev/null @@ -1,32 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function row = layoutRow(parent, logicalRow) -%LAYOUTROW Map a workbench tab's logical row to its physical grid row. -% -% Inputs: -% parent - workbench tab grid or ordinary grid. -% logicalRow - row index used by app code. -% -% Output: -% row - physical grid row. Ordinary grids return logicalRow unchanged. - - row = logicalRow; - if isempty(logicalRow) || ~isprop(parent, 'UserData') - return; - end - - data = parent.UserData; - if ~isstruct(data) || ~isfield(data, 'LabKitLogicalRowMap') - return; - end - - rowMap = data.LabKitLogicalRowMap; - for k = 1:numel(logicalRow) - idx = logicalRow(k); - if isnumeric(idx) && isfinite(idx) && idx >= 1 && idx <= numel(rowMap) && idx == floor(idx) - row(k) = rowMap(idx); - end - end -end diff --git a/+labkit/+ui/+view/private/logPanel.m b/+labkit/+ui/+view/private/logPanel.m deleted file mode 100644 index dbeeaaf..0000000 --- a/+labkit/+ui/+view/private/logPanel.m +++ /dev/null @@ -1,29 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function ui = logPanel(parent, row, initialValue) -%CREATELOGPANEL Create a log panel with a read-only text area. -% -% Inputs: -% parent - parent grid. -% row - optional logical parent row, default 1. -% initialValue - optional cellstr/string log lines, default {'GUI started.'}. -% -% Output: -% ui - struct with panel, grid, and textArea fields. - - if nargin < 2 || isempty(row) - row = 1; - end - if nargin < 3 - initialValue = {'GUI started.'}; - end - - opts = struct('rowHeight', {{'1x'}}, 'columnWidth', {{'1x'}}); - ui = labkit.ui.view.section(parent, 'Log', row, [1 1], opts); - - ui.textArea = uitextarea(ui.grid, ... - 'Editable', 'off', ... - 'Value', initialValue); -end diff --git a/+labkit/+ui/+view/private/resolveControl.m b/+labkit/+ui/+view/private/resolveControl.m new file mode 100644 index 0000000..ff5597a --- /dev/null +++ b/+labkit/+ui/+view/private/resolveControl.m @@ -0,0 +1,31 @@ +% Private UI view helper. Expected caller: named labkit.ui.view helpers. +% Resolves a semantic control id against a UI 2.0 registry, or passes through +% an adapter struct already returned by the registry. +function control = resolveControl(uiOrControl, id) + if nargin < 2 + id = ""; + end + + if isstruct(uiOrControl) && isfield(uiOrControl, 'kind') && ... + isfield(uiOrControl, 'id') + control = uiOrControl; + return; + end + + if ~(isstruct(uiOrControl) && isfield(uiOrControl, 'controls')) + error('labkit:ui:view:InvalidRegistry', ... + 'Expected a UI registry struct returned by labkit.ui.app.create.'); + end + + if strlength(string(id)) == 0 + error('labkit:ui:view:MissingControlId', ... + 'A semantic control id is required.'); + end + + name = char(string(id)); + if ~isfield(uiOrControl.controls, name) + error('labkit:ui:view:UnknownControl', ... + 'Unknown UI control "%s".', name); + end + control = uiOrControl.controls.(name); +end diff --git a/+labkit/+ui/+view/private/resultTable.m b/+labkit/+ui/+view/private/resultTable.m deleted file mode 100644 index 3c39f14..0000000 --- a/+labkit/+ui/+view/private/resultTable.m +++ /dev/null @@ -1,28 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function ui = resultTable(parent, titleText, row, columnNames, initialData) -%CREATERESULTTABLEPANEL Create a titled result-table panel. -% -% Inputs: -% parent - parent grid. -% titleText - panel title. -% row - logical parent row. -% columnNames - cellstr/string column names. -% initialData - optional initial table Data, default empty cell array. -% -% Output: -% ui - struct with panel, grid, and table fields. - - if nargin < 5 - initialData = cell(0, numel(columnNames)); - end - - opts = struct('rowHeight', {{'1x'}}, 'columnWidth', {{'1x'}}); - ui = labkit.ui.view.section(parent, titleText, row, [1 1], opts); - - ui.table = uitable(ui.grid); - ui.table.ColumnName = columnNames; - ui.table.Data = initialData; -end diff --git a/+labkit/+ui/+view/private/textPanel.m b/+labkit/+ui/+view/private/textPanel.m deleted file mode 100644 index 657c9a3..0000000 --- a/+labkit/+ui/+view/private/textPanel.m +++ /dev/null @@ -1,45 +0,0 @@ -% Private UI view helper. Expected caller: labkit.ui.view panel, control, -% plot, or text facades. Inputs and outputs are internal UI handles, labels, -% selections, table data, or plot info. Side effects are limited to supplied UI -% parents or axes; assumes the caller owns callbacks and app state. -function ui = textPanel(parent, titleText, row, lines, opts) -%CREATEREADONLYTEXTPANEL Create a titled read-only multi-line text panel. -% -% Inputs: -% parent - parent grid. -% titleText - panel title. -% row - logical parent row. -% lines - cellstr/string lines, default empty. -% opts - optional struct. -% -% Options: -% panelOptions - struct forwarded to labkit.ui.view.section. -% -% Output: -% ui - struct with panel, grid, and textArea fields. - - if nargin < 4 || isempty(lines) - lines = {}; - end - if nargin < 5 - opts = struct(); - end - - panelOpts = struct( ... - 'rowHeight', {{'1x'}}, ... - 'columnWidth', {{'1x'}}); - if isfield(opts, 'panelOptions') - panelOpts = mergeStruct(panelOpts, opts.panelOptions); - end - - ui = labkit.ui.view.section(parent, titleText, row, [1 1], panelOpts); - ui.textArea = uitextarea(ui.grid, 'Editable', 'off'); - ui.textArea.Value = lines; -end - -function out = mergeStruct(out, in) - fields = fieldnames(in); - for k = 1:numel(fields) - out.(fields{k}) = in.(fields{k}); - end -end diff --git a/+labkit/+ui/+view/resetAxes.m b/+labkit/+ui/+view/resetAxes.m new file mode 100644 index 0000000..6988c0a --- /dev/null +++ b/+labkit/+ui/+view/resetAxes.m @@ -0,0 +1,44 @@ +function resetAxes(ui, id, titleText, resetScaleAndTicks, axisId) +%RESETAXES Reset a UI 2.0 previewArea axes. +% +% App-facing contract: +% labkit.ui.view.resetAxes(ui, id, titleText, resetScaleAndTicks, axisId) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - semantic id for a previewArea. +% titleText - optional axes title. +% resetScaleAndTicks - optional logical, default false. +% axisId - optional named axes id. +% +% Output: +% None. + + if nargin < 3 + titleText = ''; + end + if nargin < 4 + resetScaleAndTicks = false; + end + if nargin < 5 + axisId = ""; + end + control = resolveControl(ui, id); + ax = controlAxes(control, axisId); + cla(ax, 'reset'); + ax.NextPlot = 'replace'; + ax.XLimMode = 'auto'; + ax.YLimMode = 'auto'; + if resetScaleAndTicks + ax.XScale = 'linear'; + ax.YScale = 'linear'; + ax.XTickMode = 'auto'; + ax.YTickMode = 'auto'; + end + title(ax, titleText); + xlabel(ax, ''); + ylabel(ax, ''); + grid(ax, 'off'); + box(ax, 'on'); + enablePopout(ax); +end diff --git a/+labkit/+ui/+view/section.m b/+labkit/+ui/+view/section.m deleted file mode 100644 index 1f958f1..0000000 --- a/+labkit/+ui/+view/section.m +++ /dev/null @@ -1,115 +0,0 @@ -function ui = section(parent, titleText, row, gridSize, opts) -%CREATEPANELGRID Create a standard titled panel containing a grid layout. -% -% Usage: -% ui = labkit.ui.view.section(parent, 'Inputs', 1, [3 2]); -% ui = labkit.ui.view.section(parent, 'Inputs', 1, [3 2], ... -% struct('rowHeight', {{'fit','fit','1x'}}, 'columnWidth', {{120,'1x'}})); -% -% Inputs: -% parent - uigridlayout or compatible MATLAB UI parent. -% titleText - panel title. -% row - logical parent row. Use [] when caller assigns Layout manually. -% gridSize - child grid size [rows columns]. -% opts - optional struct. -% -% Options: -% rowHeight - child grid RowHeight, default all {'fit'}. -% columnWidth - child grid ColumnWidth, default all {'1x'}. -% padding - child grid Padding, default [8 8 8 8]. -% rowSpacing - child grid RowSpacing, default 8. -% columnSpacing - child grid ColumnSpacing, default 8. -% autoGrowParentRow - logical, default true. -% minPanelHeight - scalar minimum parent row height when auto-growing. -% -% Output: -% ui - struct with panel and grid fields. - - if nargin < 5 - opts = struct(); - end - - ui = struct(); - ui.panel = uipanel(parent, 'Title', titleText); - if nargin >= 3 && ~isempty(row) - ui.panel.Layout.Row = layoutRow(parent, row); - end - - ui.grid = uigridlayout(ui.panel, gridSize); - ui.grid.RowHeight = optionValue(opts, 'rowHeight', defaultRowHeight(gridSize)); - ui.grid.ColumnWidth = optionValue(opts, 'columnWidth', defaultColumnWidth(gridSize)); - ui.grid.Padding = optionValue(opts, 'padding', [8 8 8 8]); - ui.grid.RowSpacing = optionValue(opts, 'rowSpacing', 8); - ui.grid.ColumnSpacing = optionValue(opts, 'columnSpacing', 8); - if exist('row', 'var') && ~isempty(row) - autoGrowParentRow(parent, ui.panel.Layout.Row, gridSize, ui.grid, opts); - end -end - -function rowHeight = defaultRowHeight(gridSize) - rowHeight = repmat({'fit'}, 1, gridSize(1)); -end - -function columnWidth = defaultColumnWidth(gridSize) - if gridSize(2) == 2 - columnWidth = {'fit', '1x'}; - else - columnWidth = repmat({'1x'}, 1, gridSize(2)); - end -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isfield(opts, name) - value = opts.(name); - end -end - -function autoGrowParentRow(parent, row, gridSize, childGrid, opts) - if ~optionValue(opts, 'autoGrowParentRow', true) || ... - isempty(parent) || ~isvalid(parent) || ~isprop(parent, 'RowHeight') - return; - end - - rowHeights = parent.RowHeight; - if isnumeric(rowHeights) - rowHeights = num2cell(rowHeights); - end - if row > numel(rowHeights) || ~isnumeric(rowHeights{row}) - return; - end - - minHeight = optionValue(opts, 'minPanelHeight', ... - estimatePanelHeight(gridSize, childGrid.RowHeight, childGrid.RowSpacing, childGrid.Padding)); - if rowHeights{row} < minHeight - rowHeights{row} = minHeight; - parent.RowHeight = rowHeights; - end -end - -function height = estimatePanelHeight(gridSize, rowHeight, rowSpacing, padding) - titleHeight = 24; - defaultFitHeight = 24; - defaultFlexHeight = 80; - borderAllowance = 8; - height = titleHeight + borderAllowance + padding(2) + padding(4); - - if ~iscell(rowHeight) - rowHeight = num2cell(rowHeight); - end - for k = 1:gridSize(1) - item = rowHeight{k}; - if isnumeric(item) - height = height + item; - elseif ischar(item) || isstring(item) - if strcmpi(char(item), 'fit') - height = height + defaultFitHeight; - else - height = height + defaultFlexHeight; - end - else - height = height + defaultFitHeight; - end - end - height = height + max(gridSize(1) - 1, 0) * rowSpacing; -end diff --git a/+labkit/+ui/+view/setEnabled.m b/+labkit/+ui/+view/setEnabled.m new file mode 100644 index 0000000..ab8842d --- /dev/null +++ b/+labkit/+ui/+view/setEnabled.m @@ -0,0 +1,36 @@ +function setEnabled(ui, id, enabled) +%SETENABLED Set Enable state for a UI 2.0 control or compound control. +% +% App-facing contract: +% labkit.ui.view.setEnabled(ui, id, enabled) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - globally unique semantic control id. +% enabled - logical or MATLAB on/off text. +% +% Output: +% None. All Enable-bearing handles inside the control adapter are updated. + + control = resolveControl(ui, id); + handles = controlHandles(control); + enableText = onOff(enabled); + for k = 1:numel(handles) + handle = handles{k}; + if isprop(handle, 'Enable') + handle.Enable = enableText; + end + end +end + +function text = onOff(value) + if islogical(value) && isscalar(value) + if value + text = 'on'; + else + text = 'off'; + end + else + text = char(string(value)); + end +end diff --git a/+labkit/+ui/+view/setListItems.m b/+labkit/+ui/+view/setListItems.m new file mode 100644 index 0000000..00bd392 --- /dev/null +++ b/+labkit/+ui/+view/setListItems.m @@ -0,0 +1,27 @@ +function setListItems(ui, id, items) +%SETLISTITEMS Replace the items of a UI 2.0 list-bearing control. +% +% App-facing contract: +% labkit.ui.view.setListItems(ui, id, items) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - semantic id for a pathPanel or list-bearing control. +% items - cell array or string array of display names. +% +% Output: +% None. + + control = resolveControl(ui, id); + listbox = listboxHandle(control); + refreshListboxItems(listbox, items); +end + +function listbox = listboxHandle(control) + if isfield(control, 'listbox') && isgraphics(control.listbox) + listbox = control.listbox; + return; + end + error('labkit:ui:view:NoListbox', ... + 'Control "%s" does not expose a listbox.', control.id); +end diff --git a/+labkit/+ui/+view/setListSelection.m b/+labkit/+ui/+view/setListSelection.m new file mode 100644 index 0000000..14f5e90 --- /dev/null +++ b/+labkit/+ui/+view/setListSelection.m @@ -0,0 +1,37 @@ +function varargout = setListSelection(ui, id, items, preferred, opts) +%SETLISTSELECTION Apply list selection for a UI 2.0 list-bearing control. +% +% App-facing contract: +% [value, index] = labkit.ui.view.setListSelection(ui, id, items, preferred, opts) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - semantic id for a pathPanel or list-bearing control. +% items - display item list. +% preferred - preferred selected item, items, or index. +% opts - optional selection policy struct. +% +% Outputs: +% value - applied listbox value. +% index - applied selection indices. + + if nargin < 4 + preferred = []; + end + if nargin < 5 + opts = struct(); + end + control = resolveControl(ui, id); + if ~isfield(control, 'listbox') || ~isgraphics(control.listbox) + error('labkit:ui:view:NoListbox', ... + 'Control "%s" does not expose a listbox.', control.id); + end + [value, index] = refreshListboxSelection(control.listbox, ... + items, preferred, opts); + if nargout >= 1 + varargout{1} = value; + end + if nargout >= 2 + varargout{2} = index; + end +end diff --git a/+labkit/+ui/+view/setValue.m b/+labkit/+ui/+view/setValue.m new file mode 100644 index 0000000..49d3cbf --- /dev/null +++ b/+labkit/+ui/+view/setValue.m @@ -0,0 +1,56 @@ +function setValue(ui, id, value) +%SETVALUE Set a UI 2.0 control value through the semantic registry. +% +% App-facing contract: +% labkit.ui.view.setValue(ui, id, value) +% +% Inputs: +% ui - UI registry returned by labkit.ui.app.create. +% id - globally unique semantic control id. +% value - value assigned to the control's primary value handle. +% +% Output: +% None. Programmatic updates suppress callbacks only where the underlying +% MATLAB component would otherwise call them synchronously. + + control = resolveControl(ui, id); + if isfield(control, 'setValue') && isa(control.setValue, 'function_handle') + control.setValue(value); + return; + end + handle = controlValueHandle(control); + if ~isprop(handle, 'Value') || isequaln(handle.Value, value) + return; + end + callback = callbackProperty(handle); + cleanupObj = suppressCallback(handle, callback); + handle.Value = value; + clear cleanupObj; +end + +function callback = callbackProperty(handle) + callback = struct('property', '', 'value', []); + for name = {'ValueChangedFcn', 'ButtonPushedFcn'} + prop = name{1}; + if isprop(handle, prop) + callback.property = prop; + callback.value = handle.(prop); + return; + end + end +end + +function cleanupObj = suppressCallback(handle, callback) + if isempty(callback.property) + cleanupObj = onCleanup(@() []); + return; + end + handle.(callback.property) = []; + cleanupObj = onCleanup(@() restoreCallback(handle, callback)); +end + +function restoreCallback(handle, callback) + if ~isempty(handle) && isvalid(handle) && isprop(handle, callback.property) + handle.(callback.property) = callback.value; + end +end diff --git a/+labkit/+ui/+view/update.m b/+labkit/+ui/+view/update.m deleted file mode 100644 index 2cd43ff..0000000 --- a/+labkit/+ui/+view/update.m +++ /dev/null @@ -1,53 +0,0 @@ -function varargout = update(target, action, varargin) -%UPDATE Apply an app-neutral state update to an existing UI handle group. -% -% App-facing contract: -% labkit.ui.view.update(textArea, "appendLog", message) -% labkit.ui.view.update(listbox, "listItems", names) -% [value, idx] = labkit.ui.view.update(listbox, "listSelection", names, preferred, opts) -% -% Inputs: -% target - MATLAB handle or LabKit component struct returned by panel(). -% action - state update action. -% varargin - action-specific payload described above. -% -% Output: -% listSelection returns the applied value and selected indices. Other -% actions mutate target in place and return [] when captured. - - switch normalizeAction(action) - case 'appendlog' - appendLog(target, positional(varargin, 1, '')); - out = {[]}; - case 'listitems' - refreshListboxItems(target, positional(varargin, 1, {})); - out = {[]}; - case 'listselection' - names = positional(varargin, 1, {}); - preferredSelection = positional(varargin, 2, target.Value); - opts = positional(varargin, 3, struct()); - [value, index] = refreshListboxSelection(target, names, preferredSelection, opts); - out = {value, index}; - otherwise - error('labkit_ui:update:UnknownAction', ... - 'Unknown LabKit view update action "%s".', char(action)); - end - - for k = 1:min(nargout, numel(out)) - varargout{k} = out{k}; - end - for k = (numel(out) + 1):nargout - varargout{k} = []; - end -end - -function action = normalizeAction(action) - action = lower(regexprep(char(string(action)), '[^a-zA-Z0-9]', '')); -end - -function value = positional(args, index, defaultValue) - value = defaultValue; - if numel(args) >= index && ~isempty(args{index}) - value = args{index}; - end -end diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index d1006b9..3cc0f95 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -20,7 +20,7 @@ - `labkit.biosignal` stays GUI-free and independent from DTA/app code. - `labkit.ui` stays parser/data/analysis-free; apps pass prepared values, labels, tables, callbacks, and handles into UI helpers. - Reusable UI tools may own domain-neutral interaction workflows such as image scale-bar controls, reference editing, unit normalization, and overlay placement. Keep those tools independent from app result schemas, scientific formulas, file formats, and workflow wording. -- App-facing UI APIs live under `labkit.ui.app.*`, `labkit.ui.view.*`, `labkit.ui.tool.*`, and `labkit.ui.diag.*`. Do not reintroduce flat `labkit.ui.*` helper files. +- App-facing UI APIs live under `labkit.ui.app.*`, `labkit.ui.spec.*`, `labkit.ui.view.*`, `labkit.ui.tool.*`, and `labkit.ui.diag.*`. Do not reintroduce flat `labkit.ui.*` helper files. - Image-axis tools that need pointer, drag, scroll, or hit-test ownership must use `labkit.ui.tool.createRuntime` sessions instead of each helper managing figure/axes callbacks independently. - Tool callbacks must keep user-facing semantic callbacks separate from internal refresh/sync callbacks, no-op when a setter receives the current value, and trace callback reason/source when a tool exposes debug trace. - Do not introduce MATLAB classes unless explicitly approved. diff --git a/.agents/migration_guide.md b/.agents/migration_guide.md index 62273b0..8f1e5fb 100644 --- a/.agents/migration_guide.md +++ b/.agents/migration_guide.md @@ -1,21 +1,20 @@ # Agent Migration Ledger -This is the agent-facing zero-debt ledger for LabKit migrations. It is not an -architecture manual, validation matrix, or historical changelog. +This is the agent-facing migration ledger for LabKit. It is not an +architecture manual, validation matrix, historical changelog, or standalone +roadmap. Human-facing architecture and app behavior live in `docs/`. Exact validation -commands live in `docs/testing.md` and are routed through `labkit-test-planner`. -This ledger owns only migration debt facts, retirement rules, and the minimum -standard for handling future migration debt. +commands live in `docs/testing.md` and are routed through +`labkit-test-planner`. This ledger owns only active migration debt facts, +retirement rules, and the minimum standard for handling future migration debt. ## Lifecycle Update this ledger when migration debt is added, reduced, retired, or reprioritized. Keep it aligned with: -- `ProjectDebtGuardrailTest.expectedOversizedRunnerDebtFiles` -- `ProjectDebtGuardrailTest.expectedAppPrivateDebtFiles` -- `ProjectDocumentationGuardrailTest.expectedPrivateContractDebtFiles` +- Exact expected-debt inventories in project guardrail tests - `ProjectStructureGuardrailTest` package and startup path checks - `docs/architecture.md` for human-facing boundary facts @@ -27,16 +26,36 @@ the same change. A completed migration should not remain as active roadmap text. Current active migration debt: ```text -none +ui-2-declarative-spec ``` Current facts: - Oversized app entry points: none. -- Oversized app `+ui/runApp.m` runners over 500 lines: none. +- Oversized package-root app `run.m` runners over 500 lines: none. - App `private/` debt: none. - `+labkit` private helper contract debt: none. - String-dispatch workflow adapters and app `+core/dispatch.m` routers: none. +- UI app-facing style debt is resolved: `createShell`, `tab`, `section`, + `form`, `panel`, `draw`, `update`, and `place` have been removed from the + public app-facing surface. +- The UI 2.0 foundation has landed: public spec constructors, + `labkit.ui.app.create`, named view helpers, GUI-free validation, + public-surface guardrails, and reusable UI structural tests exist. +- The first migrated app slice is complete: `labkit_ImageMatch_app`, + `labkit_ImageEnhance_app`, and `labkit_FocusStack_app` now launch through + `labkit.ui.app.create` and no longer carry ordinary old-UI layout code. +- Current image-app evidence still supports a narrow public surface. The + migrated apps needed `pathPanel.selectionMode`, + `pathPanel.onSelectionChange`, and `previewArea.onModeChange`, but they did + not justify public primitive constructors or a larger file-panel API. +- App structure governance is now part of the UI 2.0 migration contract: + migrated apps keep their ordinary data-only spec in + `+/+ui/buildSpec.m`, route extracted production code through + role-based app-owned component packages, and avoid generic helper buckets. +- Human docs, scoped `AGENTS.md`, repo skills, public-surface guardrails, and + GUI structural tests should describe the implemented foundation as current + behavior while keeping app migration order in this ledger. Completed migration baseline: ECG Print, DIC Preprocess, DIC Postprocess, CIC runner normalization, and CSC runner normalization are complete. Treat them as @@ -45,6 +64,726 @@ guarded baselines, not active phases. No active runner maps exist. If a future guardrail records new runner debt, add only a narrow map for the specific file and delete it when the debt is retired. +## Active Migration: UI 2.0 Declarative Spec + +### Goal + +Replace the pre-2.0 app-facing UI construction style with a declarative, +semantic UI contract: + +```text +apps describe controls, sections, previews, logs, and callbacks ++labkit.ui owns layout mechanics, registries, resize behavior, and app-neutral +updates +apps keep workflow semantics, calculations, plotting annotations, exports, and +log wording +``` + +The migration is worthwhile only if it removes real app-author burden and +prevents the same style drift from returning. It must not be a cosmetic move +from large app files into large framework helpers. + +### Problems To Eliminate + +The current UI facade fixed the old flat `labkit.ui.*` sprawl, but app code +The removed pre-2.0 surface leaked low-level layout and action mechanics: + +- app specs pass `gridSize`, `rowHeight`, `resizeRows`, `rightGridSize`, and + `rightRowHeight` +- app-owned code manually set `Layout.Row` and `Layout.Column` +- app and test code depended on row placement rather than semantic control + identity +- app-local `place(...)` helpers hid but did not remove physical layout + coupling +- `labkit.ui.view.draw` and `labkit.ui.view.update` used string actions and + varargs instead of named operations +- file/log/table/text panels encoded special cases under one action-style + `panel` surface +- GUI structural tests over-asserted row/column details for reusable UI helpers + +### Stable Minimal UI 2.0 Surface + +UI 2.0 is a narrow framework for LabKit workbench apps, not a general MATLAB GUI +DSL. Public spec APIs express stable LabKit app shapes. MATLAB primitive controls +are implementation details unless repeated real app use proves that a primitive +has become a stable LabKit app shape. + +The stable app-facing surface is: + +```text +labkit.ui.app.create +labkit.ui.app.dispatchRequest +labkit.ui.app.runBusy + +labkit.ui.spec.app +labkit.ui.spec.workspace +labkit.ui.spec.tab +labkit.ui.spec.section +labkit.ui.spec.field +labkit.ui.spec.rangeField +labkit.ui.spec.action +labkit.ui.spec.actionGroup +labkit.ui.spec.pathPanel +labkit.ui.spec.previewArea +labkit.ui.spec.resultTable +labkit.ui.spec.logPanel +labkit.ui.spec.statusPanel +labkit.ui.spec.custom + +labkit.ui.view.setValue +labkit.ui.view.getValue +labkit.ui.view.setEnabled +labkit.ui.view.appendLog +labkit.ui.view.setListItems +labkit.ui.view.setListSelection +labkit.ui.view.drawImage +labkit.ui.view.resetAxes +labkit.ui.view.clearAxes +``` + +Existing reusable tool and diagnostic facades remain app-facing support surfaces, +but they are not part of the ordinary form/layout grammar: + +```text +labkit.ui.tool.createRuntime +labkit.ui.tool.anchorEditor +labkit.ui.tool.scaleBar +labkit.ui.tool.scaleBarCalibration + +labkit.ui.diag.createContext +``` + +These APIs are intentionally not public UI 2.0 spec constructors: + +```text +labkit.ui.spec.group +labkit.ui.spec.button +labkit.ui.spec.buttonRow +labkit.ui.spec.dropdown +labkit.ui.spec.spinner +labkit.ui.spec.slider +labkit.ui.spec.edit +labkit.ui.spec.readonly +labkit.ui.spec.label +labkit.ui.spec.helpText +labkit.ui.spec.statusText +labkit.ui.spec.checkbox +labkit.ui.spec.switch +labkit.ui.spec.radioGroup +labkit.ui.spec.segmented +labkit.ui.spec.listbox +labkit.ui.spec.textarea +labkit.ui.spec.table +labkit.ui.spec.axes +labkit.ui.spec.imageAxes +labkit.ui.spec.previewAxes +labkit.ui.spec.previewPair +labkit.ui.spec.previewStack +labkit.ui.spec.logTab +labkit.ui.spec.axesControlStrip +``` + +`group` is excluded from v2.0 because it is too easy to turn into a hidden +layout DSL. If a later app proves a semantic grouping shape is necessary, add it +only through the public promotion rule below and do not expose row, column, flex, +span, or sizing knobs. + +Final 2.0 must remove these pre-2.0 public app-facing APIs: + +```text +labkit.ui.app.createShell +labkit.ui.app.tab +labkit.ui.view.section +labkit.ui.view.form +labkit.ui.view.panel +labkit.ui.view.draw +labkit.ui.view.update +labkit.ui.view.place +``` + +The final UI 2.0 surface has removed these APIs. Do not add a compatibility +bridge that makes old app calls permanent, and do not document the old surface +as supported. + +### Spec Shape Decisions + +- `labkit.ui.spec.*` constructors return data only. They must not create MATLAB + UI handles. +- Every spec constructor returns one scalar spec struct with the common shape + `kind`, `id`, `props`, `children`, and `slots`. Extra constructor options live + under `props`; builders must not add ad hoc top-level fields. +- The default UI 2.0 app layout remains the LabKit workbench: `controlTabs` for + the left control pane and `workspace` for the right preview/canvas/plot pane. + Do not model the primary preview workspace as a normal left tab. +- `workspace` replaces old app-facing `rightGridSize`, `rightRowHeight`, and + `rightTitle` options. Apps describe workspace content semantically; framework + policy owns physical right-side grid rows, sizing, scroll behavior, and + split-pane mechanics. +- Heterogeneous `children` are always represented as a cell row vector of scalar + spec structs. `children` is `{}` when empty. Do not use MATLAB struct arrays + for child lists, even when the current children happen to share fields. +- Slot values that contain specs are also cell row vectors. A single child in a + slot is still wrapped in a cell so app code does not switch shape by count. +- Every control id is globally unique within one app spec. `ui.controls.` is + the primary registry path for all controls, including controls inside tabs, + sections, workspace content, and composite families. +- Section-local paths such as `ui.sections..controls.` may exist + only as aliases to the same handles. They must not create a second namespace + that permits duplicate ids. +- `ui.controls.` is a control adapter record, not necessarily a MATLAB + primitive handle. Its internal fields are not stable public API. Stable access + goes through semantic ids, named view helpers, and callback events. +- Duplicate ids fail at GUI-free spec validation before any GUI construction. +- Spec validation is GUI-free and belongs under reusable UI non-GUI tests. +- App authors may set semantic options such as label, items, value, enabled, + callback, height class, and tooltip. They do not set physical grid rows, + columns, row heights, or right-side grid sizes. +- Label width, fit/flex heights, section spacing, resize handles, scrollability, + and preview sizing are framework policy. +- Public callbacks use `function callback(control, event)`. The semantic event + has at least `id`, `kind`, `source`, `value`, `previousValue`, `ui`, and + `rawEvent`. `source` is `user`, `programmatic`, or `internal`. Families may add + fields such as `paths`, `mode`, `layout`, `viewMode`, or `action`, but they + must not invent incompatible callback signatures. +- Programmatic view updates should not fire app-facing semantic callbacks unless + a future helper explicitly documents that behavior. +- `custom` is the only approved path for app-specific hand-written layout in a + migrated app. The app spec must call `labkit.ui.spec.custom(id, builder, opts)`; + ordinary app runners and callbacks must not create grids or set + `Layout.Row`/`Layout.Column` directly. +- The `custom` builder must be a named function in its own `.m` file with a + top-of-file implementation contract. Inline functions, nested functions, + anonymous builders, and local runner functions are not approved custom layout + builders. +- A custom builder receives a framework-owned parent container, semantic id, + context, and options; it returns a handle or struct to register at + `ui.controls.`. It may hand-write layout only inside that file. +- `custom` must not wrap ordinary controls that are covered by primitive or + composite specs, and it must not hide parsing, calculations, export behavior, + result schemas, or app-specific state mutation inside framework code. +- Composite controls should be modeled as small app-neutral families, not as one + catch-all `control(type=...)` or `panel(kind=...)` API. +- A composite family owns layout, registry shape, enabled-state propagation, and + common commands. Apps own wording, callbacks, filters, defaults, scientific + meaning, parsing, calculation, export behavior, and result schemas. +- Prefer named modes plus a few orthogonal capability options over long lists of + booleans. Reject invalid option combinations during spec validation. +- `field` has a fixed v2.0 kind whitelist: `text`, `number`, `spinner`, + `dropdown`, `slider`, `checkbox`, and `readonly`. Do not allow + `kind="custom"` or arbitrary MATLAB control names. Use `custom` for complex + controls until repeated real app use justifies a new app shape. +- `action` is public because it represents an app command. `button` is an + internal primitive implementation detail. v2.0 `action` fields are limited to + id, label, onInvoke, enabled, priority, and tooltip; defer confirmation, + busy-message, icon, color, width, and layout options until real app pressure + proves they are needed. +- `previewArea` uses `layout` for axes arrangement and `viewModes` for + user-facing preview choices. Do not overload `mode` for both concepts. +- Use slots for app-specific extra actions or notes inside a framework-owned + shell. A slot may place app-supplied specs, but the app still must not set + physical grid rows or columns. + +### Composite Family Pattern + +The strongest consolidation opportunity is not one giant control class and not a +primitive-constructor catalog. It is a compact workbench grammar: + +1. **Workbench skeleton** captures `controlTabs`, `section`, and `workspace`. +2. **Composite families** capture repeated app shells: path selection, labeled + fields, ranges, actions, previews, result/status panels, and logs. +3. **Internal primitives** map family implementations to MATLAB controls without + becoming app-facing constructors. + +`pathPanel` should replace separate ad hoc file pickers, file lists, folder +pickers, and output-folder panels. It should support named modes such as: + +```text +singleFile +multiFile +folder +multiFolder +outputFolder +``` + +Its options should be app-neutral: filters, title, selection policy, list +visibility, remove/clear buttons, status text, empty text, extra actions, and +callbacks such as `onChoose`, `onRemove`, `onClear`, and `onOpenFolder`. +It should not parse files, infer experiment types, choose export names, or +encode app-specific validation beyond path kind and selection count. + +Other composite families should follow the same pattern: + +- `field` wraps label/help/unit/default/reset/error state around one primitive + value control. +- `rangeField` represents paired start/end or min/max values under one semantic + id, without encoding ROI, time-window, or color-limit business meaning. +- `action` represents an app command. It may render as a button today, but app + code must not depend on that primitive. +- `actionGroup` owns wrapping, equal sizing, primary/secondary/destructive + visual priority, enabled-state propagation, and command grouping. +- `previewArea` owns single/pair/stacked axes layout, optional view-mode + selector, popout affordances, and linked axes policy. Stacked previews must + support named axes or an axis count so waveform-style apps do not need custom + layout just to show four synchronized axes. +- `resultTable`, `statusPanel`, and `logPanel` own common shells and empty + states while leaving rows, wording, and updates to the app. + +Do not promote a combination if its only shared part is a label beside an +app-specific algorithm control. Use `custom` or an app-local builder for those. + +### Internal Primitive Inventory + +The following primitive mappings may exist under private implementation files, +but app code must not call them as `labkit.ui.spec.*` constructors: + +```text +action -> uibutton-like command control +field(kind="text") -> text edit field +field(kind="number") -> numeric edit field +field(kind="spinner") -> spinner +field(kind="dropdown") -> dropdown +field(kind="slider") -> slider +field(kind="checkbox") -> checkbox +field(kind="readonly") -> read-only display +pathPanel -> chooser action, list/path display, status text +resultTable -> uitable +logPanel -> text area +previewArea -> axes +statusPanel -> read-only status display +``` + +Do not expand this inventory into public constructors during implementation. +If a primitive becomes necessary as a public API, it must pass the public +promotion rule and be framed as a stable LabKit app shape, not a MATLAB control +wrapper. + +### Public Spec Promotion Rule + +A new public `labkit.ui.spec.*` family may be added only when all are true: + +- it cannot be expressed cleanly by existing public families, modes, slots, or + `custom` +- at least two real apps or one broad app family need the same app-neutral shape +- its registry behavior can be described through `ui.controls.` and named + view helpers without exposing unstable handle internals +- GUI-free validation can reject invalid specs, duplicate ids, and illegal + option combinations where possible +- GUI structural tests can verify semantic ids, initial state, callback wiring, + and app-neutral behavior without row/column assertions +- it does not encode app workflow, science, parser, export, plot-label, result + schema, or log-wording semantics +- it removes app-facing layout decisions rather than hiding them in a new helper + +### App UI Scan Findings + +The UI 2.0 control set should be driven by the current app inventory, not only +by a theoretical control list. Current app-owned UI code repeatedly implements: + +- **file list panels** in electrochem apps and image apps: open files, open + folder, remove/clear, listbox, loaded-status text, and optional export/reload +- **single file pickers** in DIC and ECG workflows: open one typed file, + display selected path, and optionally preview or parse it +- **output folder pickers** in image export workflows: choose folder, show + folder, choose format, then export +- **parameter rows** across electrochem, image, DIC, and ECG apps: label plus + spinner/dropdown/edit, optional unit text, optional reset/default, and + consistent callback wiring +- **range rows** in ROI/time/color-limit workflows: paired start/end or min/max + values with one semantic id +- **section action groups**: button rows or button stacks that should wrap and + equalize without app-owned grid math +- **result/history/summary tables**: titled `uitable` areas with stable + registry ids and fixed semantic initial states +- **log panels/tabs**: text area plus append/clear/copy/debug attachment +- **workflow notes/detail text**: app-authored text areas that should be + layout-managed but remain app-worded +- **single, paired, and stacked preview axes**: one preview, top/bottom preview, + and four waveform-style axes +- **axes control strips**: X/Y dropdowns, grid/legend/marker toggles, and + optional app-provided buttons for plot refresh/swap/reset +- **preview mode selectors**: dropdown or segmented controls for original, + processed, before/after, current pair, overlay, mask, or related modes +- **tool escape hatches**: scale-bar calibration, anchor/ROI editing, crop ROI, + mask drawing, and image scroll/zoom runtime + +No current app uses `uislider`, `uiswitch`, radio groups, trees, knobs, gauges, +or date pickers directly. `slider` belongs in the v2.0 `field` kind whitelist +because it is a common parameter presentation. `switch`, `radioGroup`, +`segmented`, `tree`, `knob`, `gauge`, date/time picker, and full color picker +stay out of the v2.0 public API until a real app proves a stable LabKit app +shape needs them. + +### Framework Inclusion Tiers + +Tier 1 app-neutral composite families are required for the first usable 2.0 +vertical slice: + +```text +pathPanel +field +rangeField +action +actionGroup +previewArea +resultTable +logPanel +statusPanel +``` + +Tier 2 combinations are valuable, but should wait until at least two migrated +apps need the same shape: + +```text +axesControlStrip +plotOptions +historyPanel +detailsPanel +progressStatus +workflowNotes +``` + +Tier 3 remains app-local or `custom` unless repeated real use proves a +domain-neutral tool: + +```text +DIC mask editor +crop ROI editor +curvature fit controls +ECG template controls +electrochem-specific plot annotations +image-enhancement pipeline controls +full color picker +tree browser +toolbar/context-menu systems +``` + +Tier promotion must follow the public spec promotion rule. Do not add a public +spec constructor merely because a MATLAB primitive exists or one app would be +slightly shorter with it. + +### Current App Coverage Verdict + +The app scan supports the narrowed public surface, but it also shows where UI +2.0 should measure improvement differently by app family: + +| App family | Expected UI 2.0 improvement | Custom/tool boundary | +| --- | --- | --- | +| Image Match and Image Enhance | Strong canaries. File/export selectors, action groups, history/results, logs, and a single preview should migrate with custom count 0. | None expected for ordinary UI. | +| Focus Stack | Strong fit. Multi-file/folder input, processing fields, result table, log, and paired preview map directly to Tier 1 composites. | None expected for ordinary UI. | +| Batch Crop | Ordinary controls, path selection, crop parameters, preview, results, and logs improve. | Crop ROI and center/selection interaction may use `custom` or a tool. | +| Curvature | File controls, fit/export actions, parameters, results, and logs improve. | Scale-bar, anchor editor, zoom/runtime, and curve editing remain `labkit.ui.tool.*` or justified `custom`. | +| Electrochem CIC, CSC, VT, EIS, Chrono Overlay | File panels, parameter fields, action groups, results, logs, and single/pair plot workspaces improve. | Plot annotations, trim overlays, and specialized comparison behavior stay app-owned. `axesControlStrip` waits for v2.1 proof unless field/action sections become too repetitive. | +| DIC Postprocess | File pickers, overlay/enhancement fields, exports, results, log, and paired preview improve. | Advanced overlay interaction, if any, stays app-owned. | +| DIC Preprocess | Ordinary setup, options, log/detail panels, actions, and paired preview improve. | Mask, ROI, and crop editing are explicit custom/tool cases. | +| ECG Print | File import, processing fields, ROI range controls, summaries, logs, and waveform previews improve if `previewArea` supports stacked/named axes. | Waveform overlays and signal-window drawing stay app-owned rendering. | + +The migration is therefore beneficial for every current app, but not because +every app becomes entirely declarative. The contract is narrower: ordinary +workbench UI must become declarative and registry-driven; domain-specific +interactions stay in app code, reusable `labkit.ui.tool.*`, or named +`labkit.ui.spec.custom` builders. + +The current public list is not overdesigned relative to the inventory. The +previous risk was exposing a large primitive catalog. The revised risk profile +is: + +- **Underdesign risk:** no `detailsPanel`, `historyPanel`, or + `axesControlStrip` may leave some repeated code after the first migrations. + Keep them Tier 2 until migrated apps prove that `statusPanel`, `resultTable`, + `field`, and `actionGroup` are awkward or duplicative. +- **Overdesign risk:** `statusPanel` could become a generic hidden panel DSL, + `previewArea` could absorb plot-control semantics, and callback event fields + could grow beyond the stable app contract. Guard against this by validating + only app-neutral behavior and refusing app-specific workflow options. +- **Escape-hatch risk:** `custom` could hide ordinary form layout. Each + migration must report custom count and reason; regular form-like apps should + have custom count 0. + +### Boundary Decisions + +Reusable `+labkit` work is justified for: + +- generic app shell creation from declarative app specs +- generic control tabs, sections, workspace, composite families, registries, and + internal primitive builders +- app-neutral named view updates and rendering helpers +- app-neutral interaction lifecycle and existing tools +- diagnostics and callback instrumentation + +Keep app-local: + +- experiment names, labels that encode workflow wording, defaults, formulas, + thresholds, plot annotations, result fields, export schemas, file naming, and + alert/log wording +- DIC ROI semantics, electrochemistry result meaning, image processing + algorithms, and wearable signal analysis +- app-specific compound control choreography unless at least two real apps prove + that a domain-neutral tool belongs in `labkit.ui.tool` + +### Benefit Gates + +A UI 2.0 migration PR is not progress unless it satisfies the relevant gates: + +- migrated app entry points, or their app-owned orchestration runners when the + public file is a thin dispatch wrapper, call + `.ui.buildSpec(...)` and `labkit.ui.app.create(...)`; ordinary + `buildSpec.m` files return only data-only `labkit.ui.spec.*` trees +- extracted app-owned helpers live under role-based component packages: + `+ui`, `+state`, `+io`, `+ops`, `+view`, and `+export`; create only the + packages the app actually needs +- helper file names describe stable roles or outputs and do not use generic + buckets such as `helpers.m`, `utils.m`, `common.m`, `misc.m`, + `callbacks.m`, `manager.m`, `processor.m`, `layout.m`, or `createUI.m` +- ordinary app UI no longer writes `uigridlayout`, `Layout.Row`, + `Layout.Column`, `gridSize`, `rowHeight`, `rightGridSize`, + `rightRowHeight`, or local `place(...)` +- ordinary app UI does not call internal primitive constructors such as + `labkit.ui.spec.button`, `dropdown`, `spinner`, `slider`, `listbox`, + `textarea`, or `axes` +- migrated callbacks update UI through registry handles and named view helpers, + not string-action `draw`/`update` +- GUI structural tests assert semantic controls, tabs, preview axes, logs, + callback wiring, enabled state, and debug trace plumbing; they do not assert + exact row or column placement +- any app-owned custom UI is justified as a tool/interaction escape hatch, not + ordinary form layout +- each migrated app reports custom usage count and reason; ordinary form-like UI + should have custom count 0 +- behavior tests still cover app-owned calculations, exports, summaries, and + parsing separately from layout tests +- docs or scoped `AGENTS.md` updates happen only when their owned contract + changes, but all changed contracts are reflected in the owning docs/rules +- source-string guardrails prevent migrated style debt from returning + +Do not count a PR as successful if it only moves manual layout into a new helper +without reducing app-facing layout decisions. + +### Migration Order + +Use small, runnable, reviewable PRs. Each PR should leave the branch with a +coherent API contract and passing focused validation. + +Current implementation checkpoint: + +- Foundation is complete for the first implementation slice: stable spec + constructors, `labkit.ui.app.create`, named view helpers, validation tests, + public-surface guardrails, and docs/AGENTS/skill routing are expected to + exist. +- The first migrated app is now complete on the current branch: + `labkit_ImageMatch_app` launches through `labkit.ui.app.create`, uses the + declarative workbench, and no longer carries ordinary old-UI layout code. +- The image-editor pair is now complete on the current branch: + `labkit_ImageEnhance_app` also launches through `labkit.ui.app.create` and + confirms that ordinary image-app UI can stay within the current stable spec + surface without promoting new primitives. +- The next broader image-app slice is now complete on the current branch: + `labkit_FocusStack_app` also launches through `labkit.ui.app.create`, + confirms that paired preview workspaces fit the stable grammar, and keeps + ordinary UI custom count at 0. +- Migrated-app-driven framework additions now in use are: + `pathPanel.selectionMode`, `pathPanel.onSelectionChange`, and + `previewArea.onModeChange`. +- Focus Stack also confirms the preferred file-panel composition: use + `pathPanel` for chooser/list/count behavior and add adjacent actions such as + `Open image folder` only when the workflow genuinely needs a second load + path. Do not grow a separate public file-panel family yet. +- The next migration slice should stay in image measurement with + `labkit_BatchImageCrop_app`, because it exercises the first justified + custom/tool-heavy preview interaction while leaving ordinary controls + declarative. + +1. **Spec grammar and validation** + - Complete in the current foundation checkpoint. Future changes should be + narrow fixes, not a second planning pass. + - Add only the stable minimal public spec constructors and validation. + - Add private/internal primitive builders for `field`, `action`, + `pathPanel`, `previewArea`, `resultTable`, `logPanel`, and `statusPanel`. + - Add duplicate-id, callback event, field-kind whitelist, invalid-option, and + default-policy tests under existing `testLabkitUi`. + - Update project public-surface guardrails to allow the new `+spec` package + while rejecting public primitive constructor drift. + - Do not migrate apps yet. + +2. **Vertical app builder slice** + - Complete in the current foundation checkpoint. Future changes should be + driven by migrated app evidence. + - Add `labkit.ui.app.create` for `controlTabs`, sections, workspace, + composite families, registries, debug context, and basic resize policy. + - Add named view helpers needed by one migrated app. + - Use reusable UI GUI tests to verify semantic registry, callback wiring, + preview/log handles, and debug integration. + +3. **Initial app migration** + - Complete on the current branch with `labkit_ImageMatch_app`, because it is + recent, regular, and exposes the local + `place(...)`/preview-boilerplate style debt clearly. + - Keep app calculations, state, export, image IO, and wording app-local. + - Update only the image-measurement GUI structural contract needed for the + migrated app. + - Prove ordinary UI needs no public primitive constructors and custom + count 0. + - Keep a migrated-app guardrail that prevents old-style calls in the + migrated path. + +4. **Image editor pair** + - Complete on the current branch with `labkit_ImageEnhance_app`. + - Consolidate any API gaps revealed by the first two image apps before + migrating broad app families. + - Update `docs/ui.md`, `docs/apps.md`, `+labkit/AGENTS.md`, `apps/AGENTS.md`, + and relevant skills to state that new UI work uses the declarative API. + Mark old APIs as migration-only, not supported style. + +5. **Regular electrochemistry apps** + - Migrate CIC and VT Resistance together only if the shared top/bottom plot + pattern is already covered by public composites, internal primitives, or + approved custom tools; otherwise split them. + - Migrate CSC separately because curve comparison controls differ. + - Migrate EIS and Chrono Overlay after the single-axis and two-axis preview + patterns are stable. + - Preserve DTA facade use, calculations, exports, plot labels, and CSV + schemas. + +6. **DIC and complex image apps** + - Migrate DIC Preprocess and DIC Postprocess using spec for ordinary + controls and `custom`/tool surfaces for ROI, mask, crop, and paired-preview + interactions. + - Focus Stack is complete on the current branch. Migrate Batch Crop and + Curvature after the crop/selection custom boundary stays explicit and + scale-bar/anchor-editor/tool patterns remain app-owned. + - Do not generalize image algorithms into `+labkit`. + +7. **Wearable app** + - Migrate ECG Print after tables, waveform previews, and signal-summary + controls are represented cleanly. + - Preserve `labkit.biosignal.*` usage and app-owned export/summary behavior. + +8. **Public-surface removal** + - Delete pre-2.0 public UI API files after all app and test callers are + migrated. + - Update `PackagePublicSurfaceTest`, `ProjectStructureGuardrailTest`, + architecture helpers, docs, scoped `AGENTS.md`, and skills in the same PR. + - Add hard-fail guardrails for old calls and manual app layout mechanics. + +9. **Ledger retirement** + - Remove this active UI 2.0 section or shrink it to a short historical + invariant after the old API surface and temporary debt ledgers are gone. + +### Guardrail Plan + +Use staged guardrails so the branch remains useful throughout migration: + +- Final: hard-fail all app-owned calls to `createShell`, `tab`, `section`, + `form`, `panel`, `draw`, `update`, `place`, `rightGridSize`, + `rightRowHeight`, `resizeRows`, direct `Layout.Row`, direct `Layout.Column`, + and local `place(...)`, except in approved `custom`/tool implementation + files. +- Migrated-app structure guardrails must require the canonical `buildSpec.m` + location, reject ordinary handle/layout creation in `buildSpec.m`, reject + generic helper-bucket file names, and keep component package responsibilities + aligned with `docs/architecture.md`. +- Public-surface tests must require the target 2.0 API list and reject helper + dump packages such as `+labkit/+ui/+control`. +- Public-surface tests must reject app-facing primitive spec constructors unless + a later design review explicitly promotes one through the public spec + promotion rule. +- Migrated-family guardrails should report custom usage count and fail ordinary + form-like custom usage in migrated paths. +- Documentation guardrails must prevent scoped agent rules from recommending + deleted UI APIs. + +### Validation Plan + +Do not create a parallel runner just for UI 2.0. Use existing build tasks unless +a later implementation proves that a new task is necessary and updates +`buildfile.m`, `docs/testing.md`, scripts, and build-task guardrails together. + +Use: + +```text +buildtool testLabkitUi +buildtool testLabkitUiGui +buildtool testAppsImageMeasurementGui +buildtool testAppsElectrochemGui +buildtool testAppsDicGui +buildtool testAppsWearableGui +buildtool testAppsGui +buildtool testAppsSmokeGui +buildtool testProject +buildtool test +``` + +Validation routing: + +- spec constructors, callback event contract, field kind whitelist, custom + builder validation, and promotion guardrails: `testLabkitUi` +- app builder, layout policy, registry, resize, preview, log, diagnostics: + `testLabkitUiGui` +- migrated app-family work: affected app-family GUI task plus + `testAppsSmokeGui` +- public surface, no-legacy, no-manual-layout, docs/AGENTS/skill routing: + `testProject` +- broad API removal: `testLabkitUiGui`, `testAppsGui`, `testAppsSmokeGui`, + `testProject`, and default `test` + +CI status checks after a push should be low-noise. Before reading the new run +status, inspect the most recent successful run for the same workflow/branch, +compute its total elapsed duration, and wait at least that long. Use that +duration as the minimum interval between later status reads unless a job has +already failed and logs are needed for a fix. + +Automated GUI tests remain structural. Interactive file selection, real drawing, +visual quality, and full workflow feel require manual MATLAB GUI validation. + +### Documentation And Skill Sync + +Update each documentation surface when its owned contract changes: + +- `docs/ui.md`: rewrite when `app.create`, `spec.*`, named view helpers, custom + escape hatches, and the final public surface are implemented. It now + documents the implemented UI 2.0 foundation and final public surface; keep it + current without expanding it into a second migration roadmap. +- `docs/architecture.md`: update when the official app-facing UI surface + changes from pre-2.0 layered construction to declarative construction. +- `docs/apps.md`: update when app entrypoint guidance changes to + `labkit.ui.app.create`. +- `docs/testing.md`: update only if validation routing, task names, or GUI test + semantics change. +- `AGENTS.md`, `+labkit/AGENTS.md`, `apps/AGENTS.md`, and `tests/AGENTS.md`: + update when agent routing or ownership rules change. +- Repo skills: update `labkit-app-builder`, `labkit-boundary-guard`, and + `labkit-test-planner` guidance when the app UI/API contract changes. + +Do not update human docs with future-tense roadmap text. They should describe +the current project behavior once the corresponding implementation lands. + +### Completion Criteria + +The UI 2.0 migration is complete only when: + +- all supported app entry points launch through `labkit.ui.app.create` directly + or delegate to an app-owned orchestration runner that does +- all migrated app entry points, or their app-owned orchestration runners when + the public file is a thin dispatch wrapper, call a canonical + `+/+ui/buildSpec.m` and route extracted helpers by role +- all ordinary app UI is declared through the stable minimal + `labkit.ui.spec.*` surface +- all migrated controls, sections, tabs, preview axes, and logs are reachable + through semantic registries +- no app-owned ordinary UI code uses direct grid row/column mechanics +- no app-owned ordinary UI code calls pre-2.0 UI public APIs +- no app-owned ordinary UI code calls internal primitive spec constructors +- custom builders are limited to documented compound interactions and every + remaining custom use has a reason +- app-owned package structure guardrails enforce role-based components and + reject generic helper buckets +- old public API files are deleted or made private implementation details that + apps cannot call +- public-surface and no-legacy guardrails enforce the new contract +- docs, scoped `AGENTS.md`, and repo skills no longer recommend deleted APIs +- focused and broad automated validation pass, with manual GUI checks called + out for interactive workflows +- this ledger no longer carries an active UI 2.0 roadmap section + ## Migration Standard Apps are first-class products. `+labkit` stays a small domain-neutral foundation diff --git a/.agents/skills/labkit-app-builder/SKILL.md b/.agents/skills/labkit-app-builder/SKILL.md index cd12218..ced10bb 100644 --- a/.agents/skills/labkit-app-builder/SKILL.md +++ b/.agents/skills/labkit-app-builder/SKILL.md @@ -113,21 +113,41 @@ Use the closest existing app as the starting pattern, then reduce it to the actu Build the app in this order: -1. Add or update the app entry point with `labkit.ui.app.createShell`. -2. Wire file loading through the appropriate facade or app-local reader. -3. Store state in one app struct; avoid globals, base workspace state, and hidden local paths. -4. Rebuild the user workflow around stable controls, previews, summaries, and exports; do not reproduce command-line debug staging. -5. Move GUI-free calculations below the app `end` as app-local functions. -6. Extract production helpers into an app-owned package when the app is too - large for a readable single entry point. -7. For active runner or app-private migrations, use `labkit-migration-planner` +1. Add or update the public app entry point as a thin dispatch wrapper, then + create the GUI from package-root `run.m` with `labkit.ui.app.create` and + `labkit.ui.spec.*`. +2. Put the data-only spec in `+/+ui/buildSpec.m`; package-root + `run.m` should create callback handles, call + `.ui.buildSpec(...)`, then call `labkit.ui.app.create(...)`. +3. Keep `buildSpec.m` free of MATLAB handle creation, `labkit.ui.app.create`, + state mutation, IO, computation, export writing, nested callback + implementations, and row/column layout mechanics. Use a named + `+ui/build.m` custom builder only for a justified interaction that the + ordinary spec grammar cannot represent. +4. Wire file loading through the appropriate facade or app-local reader. +5. Store state in one app struct; avoid globals, base workspace state, and hidden local paths. +6. Rebuild the user workflow around stable controls, previews, summaries, + semantic control ids, and exports; do not reproduce command-line debug + staging. +7. Move GUI-free calculations below the app `end` as app-local functions. +8. Extract production helpers into role-based app-owned package components when + the app is too large for a readable single entry point: + `+state` for defaults/factories, `+io` for file discovery/readers/filters, + `+ops` for GUI-free transforms, `+view` for table/detail/display data, and + `+export` for output writers/manifests. Create only the packages the app + actually needs. +9. Avoid boundary-blurring helper names such as `helpers.m`, `utils.m`, + `common.m`, `misc.m`, `callbacks.m`, `manager.m`, `processor.m`, + `layout.m`, and `createUI.m`; name files by stable role or output instead. +10. For active runner or app-private migrations, use `labkit-migration-planner` to audit the current debt map and update `.agents/migration_guide.md`. -8. Do not add new `private/` runners, `*Workflow.m` string-dispatch adapters, +11. Do not add new `private/` runners, `*Workflow.m` string-dispatch adapters, fixed `+app` package names, or app-local public helper packages. -9. Render prepared data through `labkit.ui` helpers; keep analysis out of UI helpers. -10. Add export builders before CSV/PNG writing so output contracts can be tested. -11. Add focused tests with synthetic fixtures or minimal generated data. -12. Update human docs for user-facing behavior and scoped `AGENTS.md` only when rules change. +12. Render prepared data through UI 2.0 named view helpers or existing + `labkit.ui.tool.*` helpers; keep analysis out of UI helpers. +13. Add export builders before CSV/PNG writing so output contracts can be tested. +14. Add focused tests with synthetic fixtures or minimal generated data. +15. Update human docs for user-facing behavior and scoped `AGENTS.md` only when rules change. ## Validation diff --git a/.agents/skills/labkit-boundary-guard/SKILL.md b/.agents/skills/labkit-boundary-guard/SKILL.md index c3870c5..f98b0d5 100644 --- a/.agents/skills/labkit-boundary-guard/SKILL.md +++ b/.agents/skills/labkit-boundary-guard/SKILL.md @@ -12,7 +12,9 @@ Preserve LabKit's app-first architecture: - apps own experiment-specific workflow - `+labkit` owns small, stable UI/DTA/biosignal facades - no public helper-dump packages -- UI apps should use the layered `labkit.ui.app/view/tool/diag` facades; the older flat helper surface has been removed +- UI apps should use the layered `labkit.ui.app/spec/view/tool/diag` facades; + the older flat helper surface and pre-2.0 `createShell`/legacy view APIs + have been removed ## Required Read Order @@ -43,7 +45,13 @@ Before moving code into `+labkit`, prove that the helper: If this is not proven, keep the code app-local. -For UI boundary work, prefer `labkit.ui.app.createShell`, `labkit.ui.app.dispatchRequest`, `labkit.ui.diag.createContext`, `labkit.ui.tool.createRuntime`, and the unified `labkit.ui.view.section/form/panel/axes/draw/update/place` facade. Keep control micro-helpers and one-off component builders private unless they are a deliberate public facade addition. +For new or migrated UI boundary work, prefer `labkit.ui.app.create`, +`labkit.ui.spec.*`, named `labkit.ui.view.*` helpers, +`labkit.ui.app.dispatchRequest`, `labkit.ui.diag.createContext`, and +`labkit.ui.tool.createRuntime`. Keep primitive builders private; do not expose +public `labkit.ui.spec.button`, `dropdown`, `slider`, `listbox`, `table`, +`axes`, or similar MATLAB primitive constructors. Do not reintroduce +`createShell` or legacy `view.section/form/panel/axes/draw/update/place` APIs. ## Validation diff --git a/.agents/skills/labkit-migration-planner/SKILL.md b/.agents/skills/labkit-migration-planner/SKILL.md index 6564a80..bdb1eb7 100644 --- a/.agents/skills/labkit-migration-planner/SKILL.md +++ b/.agents/skills/labkit-migration-planner/SKILL.md @@ -44,14 +44,16 @@ old prose: git status --short --branch git log --oneline -n 40 find apps -path '*+ui/runApp.m' -print | sort +find apps -path '*/+*/run.m' -print | sort +find apps -path '*+ui/buildSpec.m' -print | sort find apps -path '*/private/*' -type f -print | sort -rg -n "expectedOversizedRunnerDebtFiles|expectedAppPrivateDebtFiles|expectedPrivateContractDebtFiles" tests/integration/project +rg -n "expected\\w*Debt\\w*" tests/integration/project ``` -For runner size checks, count the `+ui/runApp.m` files that matter to the -requested migration. Do not treat a line-count drop as success unless directly -tested behavior moved out of the runner and the GUI path calls the extracted -helper. +For runner size checks, count migrated package-root `run.m` files. A +`+ui/runApp.m` file is migration debt, not the final app structure. Do not +treat a line-count drop as success unless directly tested behavior moved out of +the runner and the GUI path calls the extracted helper. ## Health Review @@ -91,7 +93,11 @@ guardrails without reducing active debt or clarifying an app-facing contract. For each proposed migration, classify work as: - app-owned deterministic behavior: extract under the owning app package -- runner orchestration: leave in the public entrypoint or `+ui/runApp.m` +- migrated ordinary UI: keep the data-only spec in + `+/+ui/buildSpec.m`; use app-local custom builders only for + justified interactions +- runner orchestration: keep it in package-root `run.m` after migration; public + entrypoints stay thin wrappers - reusable foundation: use `labkit-boundary-guard` before touching `+labkit` - validation routing: use `labkit-test-planner` - documentation drift: update only the source that owns the changed contract diff --git a/.github/workflows/matlab-tests.yml b/.github/workflows/matlab-tests.yml index 15bc695..d79b2bf 100644 --- a/.github/workflows/matlab-tests.yml +++ b/.github/workflows/matlab-tests.yml @@ -2,11 +2,7 @@ name: MATLAB Tests on: push: - branches: - - main pull_request: - branches: - - main workflow_dispatch: schedule: - cron: '17 10 * * 1' diff --git a/AGENTS.md b/AGENTS.md index bd296b0..943c25a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ debt for the touched area. - Preserve behavior unless the user explicitly asks for a behavior change. - Keep app-specific formulas, thresholds, plots, result schemas, exports, and workflow decisions in the owning app. - Keep reusable `+labkit` API growth conservative and domain-neutral. -- New app-facing UI work should use `labkit.ui.app.*`, `labkit.ui.view.*`, `labkit.ui.tool.*`, and `labkit.ui.diag.*`; the older flat `labkit.ui.*` helper surface has been removed. +- New app-facing UI work should use `labkit.ui.app.*`, `labkit.ui.spec.*`, `labkit.ui.view.*`, `labkit.ui.tool.*`, and `labkit.ui.diag.*`; the older flat `labkit.ui.*` helper surface has been removed. - New app code must not call `labkit.io.*`, `labkit.data.*`, `labkit.analysis.*`, or `labkit.util.*`; use `labkit.dta.*`, `labkit.biosignal.*`, `labkit.ui.*`, or app-local helpers. - Do not reintroduce root-level legacy command wrappers, app-specific public helper packages, or public helper-dump packages such as `+labkit/+analysis`, `+data`, `+io`, or `+util`. - Do not convert struct models to MATLAB classes, rewrite all GUIs, replace separate app entry points with one launcher, or migrate code to another language without explicit approval. @@ -119,7 +119,7 @@ Interactive GUI workflows are checked manually by the user. Do not run interacti 7. Commit with a concise Conventional Commits message. 8. After a coherent series of changes is complete, check the current `main` and `origin/main` state before opening a PR. If the development branch is behind, update it only with non-destructive git operations and do not discard user work. 9. Push the completed branch, open a PR, and include the change scope, test results, unverified behavior, and any intentional follow-up work. -10. After any push that is meant to complete work, inspect the triggered CI run. Prefer low-output status checks such as `gh run list` or `gh run view --json status,conclusion,jobs`; use streaming `gh run watch` only when concise status polling is insufficient. If CI fails, read only the failing job logs, fix the underlying issue, rerun the relevant local checks, push the fix, and repeat until required CI passes. A task is not complete while required CI is red, unless CI access or infrastructure is blocked and the blocker is reported explicitly. +10. After any push that is meant to complete work, inspect the triggered CI run. Before the first status read, find the most recent successful run for the same workflow/branch and wait at least that run's total elapsed duration; use the same duration as the minimum interval between later status reads so CI polling does not become noisy. Prefer low-output status checks such as `gh run list` or `gh run view --json status,conclusion,jobs`; use streaming `gh run watch` only when concise status polling is insufficient. If CI fails, read only the failing job logs, fix the underlying issue, rerun the relevant local checks, push the fix, and repeat until required CI passes. A task is not complete while required CI is red, unless CI access or infrastructure is blocked and the blocker is reported explicitly. 11. After required CI passes and no blocking review remains, merge the PR and delete the development branch. 12. If permissions, CI, branch protection, review state, or tool availability prevent push, CI inspection, PR creation, merge, or branch deletion, stop and report the exact blocker instead of working around it. 13. Do not force-push unless explicitly approved. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index ba45820..756e71f 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -14,7 +14,8 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl - Keep domain formulas, thresholds, integration rules, option defaults, plot labels, result fields, export columns, failed-row behavior, alerts, and log wording app-local unless the user explicitly approves a boundary change. - When a documented UI tool owns app-neutral controls or interaction mechanics, consume it instead of reimplementing widget state or normalization. Keep app calculations, summaries, alerts, and exports local. -- Use `labkit.ui.app.createShell` for app GUIs. +- Use `labkit.ui.app.create` with `labkit.ui.spec.*` for app GUIs. Do not + reintroduce the removed `labkit.ui.app.createShell` or legacy view helpers. - Use `labkit.ui.app.dispatchRequest` for debug launch routing and `labkit.ui.diag.createContext` only when an app has an app-specific nonstandard request path. - Debug launches should attach the Log tab text area, emit a startup trace line, and instrument high-level component callbacks after controls are built. - Image apps with custom preview scroll, drawing, ROI, scale-bar, or other axes interaction should create a `labkit.ui.tool.createRuntime` and pass that runtime into reusable tools. Do not set image-tool `WindowScrollWheelFcn`, `WindowButtonMotionFcn`, `WindowButtonUpFcn`, or axes `ButtonDownFcn` directly in app code. @@ -26,6 +27,22 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl `+state`, `+ops`, `+view`, `+export`, and `+io` as needed. Do not use a fixed `+app` namespace; the app folder already provides ownership context, while a shared `+app` package name creates MATLAB package-resolution ambiguity. +- Apps put the ordinary data-only spec in `+/+ui/buildSpec.m`. + The public app entry point delegates to package-root `run.m`; that runner + owns state, callback closures, alerts, log wording, and refresh order. + `buildSpec.m` describes controls, sections, workspace, initial text/defaults, + and callback handles only. +- Do not create MATLAB handles, call `labkit.ui.app.create`, mutate app state, + perform IO/computation/export, or set `Layout.Row`/`Layout.Column` in + `+ui/buildSpec.m`. Use named `+ui/build.m` custom builders only for + justified interactions that cannot be expressed with the ordinary spec + grammar. +- Route helper files by role: `+state` for defaults/factories, `+io` for file + discovery/readers/filters, `+ops` for GUI-free transforms, `+view` for table + rows/detail lines/display data, and `+export` for output writers/manifests. + Do not add boundary-blurring files named `helpers.m`, `utils.m`, `common.m`, + `misc.m`, `callbacks.m`, `manager.m`, `processor.m`, `layout.m`, or + `createUI.m`. - Callback-heavy migrated apps should move app-owned production code into these package components instead of adding new `private` runners or string-dispatch workflow adapters. @@ -36,13 +53,16 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl `apps/wearable/ecg_print/+ecg_print/...` with the public command still named `labkit_ECGPrint_app`; do not create a direct `apps/wearable/+ecg_print` package. -- After an app-owned helper is extracted, remove same-named local copies from - `+ui/runApp.m` so GUI paths call the tested package helper. +- Migrated apps use a package-root `run.m` for app lifecycle orchestration. + Keep `+ui` focused on `buildSpec.m`, UI handle mapping, and justified + tool/widget glue; do not put app lifecycle runners in `+ui/runApp.m`. - Do not add new `*Workflow.m` files or app-owned `+core/dispatch.m` string routers. - When a public app file grows large, prefer moving GUI-free app-owned calculations, export builders, formatting utilities, deterministic image/signal transforms, and focused control construction into `apps///+/...`. - Do not add new `apps//private/` helpers unless the helper is genuinely shared by multiple apps in that family and the user approves that family-level boundary. -- Keep the public app entry point responsible for GUI state, callbacks, user alerts, app workflow order, debug launch routing, and user-facing log wording. +- Keep the public app entry point as a thin launch wrapper. The package-root + `run.m` owns GUI state, callbacks, user alerts, app workflow order, debug + launch routing, and user-facing log wording. ## Documentation Sync diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+ui/buildSpec.m b/apps/dic/dic_postprocess/+dic_postprocess/+ui/buildSpec.m new file mode 100644 index 0000000..881a3e5 --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+ui/buildSpec.m @@ -0,0 +1,122 @@ +% Expected caller: labkit_DICPostprocess_app. Input is a callback struct whose +% fields are app-owned callback handles. Output is a data-only UI 2.0 workbench +% spec for the DIC Postprocess app. +function spec = buildSpec(callbacks) + + spec = labkit.ui.spec.app("dicPostprocessApp", ... + "DIC Strain Postprocess", ... + "position", [90 70 1450 880], ... + "leftWidth", 390, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("inputsSection", "Inputs", { ... + labkit.ui.spec.actionGroup("inputActions", { ... + labkit.ui.spec.action("openMat", "Open DIC MAT", ... + callbackValue(callbacks, "openMat")), ... + labkit.ui.spec.action("openReference", ... + "Open reference image", callbackValue(callbacks, ... + "openReference"))}), ... + labkit.ui.spec.action("openMask", "Open mask image", ... + callbackValue(callbacks, "openMask")), ... + labkit.ui.spec.field("matPath", "DIC MAT", ... + "kind", "readonly", "value", "No MAT file loaded"), ... + labkit.ui.spec.field("referencePath", "Reference image", ... + "kind", "readonly", "value", "No reference image loaded"), ... + labkit.ui.spec.field("maskPath", "Mask image", ... + "kind", "readonly", "value", "No mask image loaded"), ... + labkit.ui.spec.action("generate", ... + "Generate overlays + summary", callbackValue(callbacks, ... + "generate"))}, ... + "height", 240), ... + labkit.ui.spec.section("overlayOptions", "Overlay Options", { ... + labkit.ui.spec.field("alpha", "Alpha:", ... + "kind", "spinner", "value", 0.60, ... + "limits", [0 1], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("colorMin", "Color min:", ... + "kind", "spinner", "value", -0.15, "step", 0.01, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("colorMax", "Color max:", ... + "kind", "spinner", "value", 0.15, "step", 0.01, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("oversample", "Oversample:", ... + "kind", "spinner", "value", 6, ... + "limits", [1 20], "step", 1, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("smoothSigma", "Smooth sigma:", ... + "kind", "spinner", "value", 0.8, ... + "limits", [0 Inf], "step", 0.1, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("exportDpi", "Export DPI:", ... + "kind", "spinner", "value", 1000, ... + "limits", [72 2400], "step", 50)}, ... + "height", 230), ... + labkit.ui.spec.section("imageOptions", ... + "Optical Image Enhancement", { ... + labkit.ui.spec.field("brightness", "Brightness:", ... + "kind", "spinner", "value", 0, ... + "limits", [-1 1], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("contrast", "Contrast:", ... + "kind", "spinner", "value", 1, ... + "limits", [0.05 5], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("gamma", "Gamma:", ... + "kind", "spinner", "value", 1, ... + "limits", [0.05 5], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("saturation", "Saturation:", ... + "kind", "spinner", "value", 1, ... + "limits", [0 5], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("redGain", "Red gain:", ... + "kind", "spinner", "value", 1, ... + "limits", [0 5], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("greenGain", "Green gain:", ... + "kind", "spinner", "value", 1, ... + "limits", [0 5], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged")), ... + labkit.ui.spec.field("blueGain", "Blue gain:", ... + "kind", "spinner", "value", 1, ... + "limits", [0 5], "step", 0.05, ... + "onChange", callbackValue(callbacks, "optionsChanged"))}, ... + "height", 260), ... + labkit.ui.spec.section("exportsSection", "Exports", { ... + labkit.ui.spec.action("saveOverlays", ... + "Save overlay PNGs", callbackValue(callbacks, ... + "saveOverlays")), ... + labkit.ui.spec.action("exportSummary", ... + "Export summary CSV", callbackValue(callbacks, ... + "exportSummary")), ... + labkit.ui.spec.action("exportColorbar", ... + "Export strain colorbar + levels", ... + callbackValue(callbacks, "exportColorbar"))}, ... + "height", 120)}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("summarySection", "ROI Strain Summary", { ... + labkit.ui.spec.resultTable("resultTable", ... + "ROI Strain Summary", ... + "columns", {'Metric', 'EXX', 'EYY'}), ... + labkit.ui.spec.statusPanel("summaryText", "Summary", ... + "value", {'No DIC result loaded.'})})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log", ... + "value", {'Ready.'})})})}, ... + "workspace", labkit.ui.spec.workspace("strainOverlays", ... + "Strain Overlays", { ... + labkit.ui.spec.previewArea("overlayAxes", "Strain Overlays", ... + "layout", "stack", "count", 2, ... + "axisIds", {'exx', 'eyy'}, ... + "axisTitles", {'EXX Overlay', 'EYY Overlay'})}, ... + "rowSpacing", 10)); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+ui/createRightAxesPair.m b/apps/dic/dic_postprocess/+dic_postprocess/+ui/createRightAxesPair.m deleted file mode 100644 index 5e676cd..0000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+ui/createRightAxesPair.m +++ /dev/null @@ -1,46 +0,0 @@ -% App-owned DIC postprocess overlay layout helper. Expected caller: -% labkit_DICPostprocess_app. Inputs are the shell UI struct, axes titles, and -% whether plot-control panels are needed. Output is the UI struct with -% top/bottom axes and panel fields. Side effects are limited to creating axes -% and optional panels on the shell right grid. -function ui = createRightAxesPair(ui, topTitle, bottomTitle, showControls) -%CREATERIGHTAXESPAIR Create DIC postprocess overlay axes. - - if showControls - ui.topControlsPanel = uipanel(ui.rightGrid, 'Title', topTitle); - ui.topControlsPanel.Layout.Row = 1; - ui.topAxes = createOneAxes(ui.rightGrid, 2, topTitle); - - ui.bottomControlsPanel = uipanel(ui.rightGrid, 'Title', bottomTitle); - ui.bottomControlsPanel.Layout.Row = 3; - ui.bottomAxes = createOneAxes(ui.rightGrid, 4, bottomTitle); - else - ui.topControlsPanel = []; - ui.bottomControlsPanel = []; - ui.topAxes = createOneAxes(ui.rightGrid, 1, topTitle); - ui.bottomAxes = createOneAxes(ui.rightGrid, 2, bottomTitle); - end -end - -function ax = createOneAxes(parent, row, titleText) - ax = uiaxes(parent); - ax.Layout.Row = row; - title(ax, titleText); - labkit.ui.view.draw(ax, 'popout'); - disableAxesInteractivity(ax); -end - -function disableAxesInteractivity(ax) - try - disableDefaultInteractivity(ax); - catch - end - try - ax.Interactions = []; - catch - end - try - ax.Toolbar.Visible = 'off'; - catch - end -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+ui/showImage.m b/apps/dic/dic_postprocess/+dic_postprocess/+ui/showImage.m index 9e688f3..caf1df5 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+ui/showImage.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+ui/showImage.m @@ -1,6 +1,7 @@ % DIC Postprocess UI helper. Expected caller: labkit_DICPostprocess_app. -% Inputs are a target axes, image data, and title. Output is the drawn image -% handle. Side effect: updates the axes. -function hImage = showImage(ax, imageData, titleText) - hImage = labkit.ui.view.draw(ax, 'image', imageData, titleText); +% Inputs are the app UI registry, image data, title, and axis id. Output is +% the drawn image handle. Side effect: updates the requested preview axes. +function hImage = showImage(ui, imageData, titleText, axisId) + hImage = labkit.ui.view.drawImage(ui, 'overlayAxes', imageData, ... + "title", titleText, "axis", axisId); end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/run.m b/apps/dic/dic_postprocess/+dic_postprocess/run.m new file mode 100644 index 0000000..faf4cfa --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/run.m @@ -0,0 +1,253 @@ +% Expected caller: labkit_DICPostprocess_app. Input is the debug context +% prepared by the public launcher. Output is the app figure. Side effects are +% GUI creation, user-driven DIC file loading, overlay export, and debug trace attachment. +function fig = run(debugLog) +%RUN Build and run the DIC Postprocess app body. + + S = struct(); + S.matPath = ""; + S.referencePath = ""; + S.maskPath = ""; + S.strain = struct(); + S.referenceImage = []; + S.maskImage = []; + S.overlayExx = []; + S.overlayEyy = []; + S.summaryTable = table(); + + callbacks = struct( ... + "openMat", @onOpenMat, ... + "openReference", @onOpenReference, ... + "openMask", @onOpenMask, ... + "generate", @onGenerate, ... + "optionsChanged", @onOptionsChanged, ... + "saveOverlays", @onSaveOverlays, ... + "exportSummary", @onExportSummary, ... + "exportColorbar", @onExportColorbar); + spec = dic_postprocess.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + fig = ui.figure; + + txtMat = ui.controls.matPath.valueHandle; + txtReference = ui.controls.referencePath.valueHandle; + txtMask = ui.controls.maskPath.valueHandle; + edAlpha = ui.controls.alpha.valueHandle; + edMin = ui.controls.colorMin.valueHandle; + edMax = ui.controls.colorMax.valueHandle; + edOversample = ui.controls.oversample.valueHandle; + edSigma = ui.controls.smoothSigma.valueHandle; + edResolution = ui.controls.exportDpi.valueHandle; + edBrightness = ui.controls.brightness.valueHandle; + edContrast = ui.controls.contrast.valueHandle; + edGamma = ui.controls.gamma.valueHandle; + edSaturation = ui.controls.saturation.valueHandle; + edRedGain = ui.controls.redGain.valueHandle; + edGreenGain = ui.controls.greenGain.valueHandle; + edBlueGain = ui.controls.blueGain.valueHandle; + resultTable = ui.controls.resultTable.table; + txtSummary = ui.controls.summaryText.textArea; + ui.topAxes = ui.controls.overlayAxes.axesById.exx; + ui.bottomAxes = ui.controls.overlayAxes.axesById.eyy; + if debugLog.enabled + debugLog.trace('DIC postprocess debug trace enabled.'); + end + + labkit.ui.view.resetAxes(ui, 'overlayAxes', 'EXX Overlay', true, 'exx'); + labkit.ui.view.resetAxes(ui, 'overlayAxes', 'EYY Overlay', true, 'eyy'); + + function onOpenMat(~, ~) + [f, p] = uigetfile({'*.mat', 'MAT files (*.mat)'}, 'Select Ncorr DIC MAT file'); + if isequal(f, 0) + addLog('DIC MAT selection cancelled.'); + return; + end + S.matPath = string(fullfile(p, f)); + txtMat.Value = char(S.matPath); + addLog(sprintf('Selected DIC MAT: %s', S.matPath)); + refreshSummaryText(); + end + + function onOpenReference(~, ~) + filepath = dic_postprocess.io.chooseImageFile('Select reference image'); + if filepath == "" + addLog('Reference image selection cancelled.'); + return; + end + S.referencePath = filepath; + S.referenceImage = imread(filepath); + txtReference.Value = char(filepath); + addLog(sprintf('Loaded reference image: %s', filepath)); + refreshSummaryText(); + end + + function onOpenMask(~, ~) + filepath = dic_postprocess.io.chooseImageFile('Select mask image'); + if filepath == "" + addLog('Mask image selection cancelled.'); + return; + end + S.maskPath = filepath; + S.maskImage = imread(filepath); + txtMask.Value = char(filepath); + addLog(sprintf('Loaded mask image: %s', filepath)); + refreshSummaryText(); + end + + function onGenerate(~, ~) + if strlength(S.matPath) == 0 || isempty(S.referenceImage) || isempty(S.maskImage) + uialert(fig, 'Load the DIC MAT file, reference image, and mask image first.', ... + 'Missing inputs'); + return; + end + if edMax.Value <= edMin.Value + uialert(fig, 'Color max must be greater than color min.', 'Invalid color range'); + return; + end + + try + S.strain = dic_postprocess.io.loadNcorrStrain(char(S.matPath)); + renderOverlays(true); + addLog('Generated EXX/EYY overlays and ROI summary.'); + catch ME + uialert(fig, ME.message, 'DIC postprocess error'); + addLog(sprintf('Generate failed: %s', ME.message)); + end + end + + function onSaveOverlays(~, ~) + if isempty(S.overlayExx) || isempty(S.overlayEyy) + uialert(fig, 'Generate overlays before saving.', 'Save overlays'); + return; + end + + folder = uigetdir(pwd, 'Select folder for overlay PNGs'); + if isequal(folder, 0) + addLog('Save overlay PNGs cancelled.'); + return; + end + + tag = dic_postprocess.view.tagFromPath(char(S.matPath)); + opts = overlayOptionsFromControls(); + exxFile = fullfile(folder, sprintf('overlay_exx_%s.png', tag)); + eyyFile = fullfile(folder, sprintf('overlay_eyy_%s.png', tag)); + dic_postprocess.export.exportOverlayFigure(S.overlayExx, 'EXX', ... + opts.colorRange, opts.exportResolution, exxFile); + dic_postprocess.export.exportOverlayFigure(S.overlayEyy, 'EYY', ... + opts.colorRange, opts.exportResolution, eyyFile); + addLog(sprintf('Saved overlay PNGs: %s and %s', exxFile, eyyFile)); + end + + function onExportSummary(~, ~) + if isempty(S.summaryTable) || height(S.summaryTable) == 0 + uialert(fig, 'Generate a summary before exporting.', 'Export summary'); + return; + end + + [folder, name] = fileparts(char(S.matPath)); + defaultName = fullfile(folder, [name '_strain_summary.csv']); + [f, p] = uiputfile('*.csv', 'Save strain summary CSV', defaultName); + if isequal(f, 0) + addLog('Export summary cancelled.'); + return; + end + + out = fullfile(p, f); + writetable(S.summaryTable, out); + addLog(sprintf('Exported summary CSV: %s', out)); + end + + function onExportColorbar(~, ~) + if edMax.Value <= edMin.Value + uialert(fig, 'Color max must be greater than color min.', 'Invalid color range'); + return; + end + + [folder, name] = fileparts(char(S.matPath)); + if isempty(folder) + folder = pwd; + end + if isempty(name) + name = 'dic_strain'; + end + defaultName = fullfile(folder, [name '_strain_colorbar.png']); + [f, p] = uiputfile({'*.png', 'PNG image'}, 'Save strain colorbar', defaultName); + if isequal(f, 0) + addLog('Export strain colorbar cancelled.'); + return; + end + + opts = overlayOptionsFromControls(); + pngOut = fullfile(p, f); + [~, baseName] = fileparts(f); + csvOut = fullfile(p, [baseName '_levels.csv']); + dic_postprocess.export.exportStrainColorbar(opts, pngOut); + writetable(dic_postprocess.view.colorbarLevelsTable(opts), csvOut); + addLog(sprintf('Exported strain colorbar: %s and %s', pngOut, csvOut)); + end + + function onOptionsChanged(~, ~) + if isfield(S.strain, 'exx') && ~isempty(S.referenceImage) && ~isempty(S.maskImage) + try + renderOverlays(false); + catch ME + addLog(sprintf('Option update skipped: %s', ME.message)); + end + end + end + + function renderOverlays(showAlerts) + if edMax.Value <= edMin.Value + if showAlerts + uialert(fig, 'Color max must be greater than color min.', 'Invalid color range'); + end + return; + end + + opts = overlayOptionsFromControls(); + overlayMask = dic_postprocess.ops.imageMask(S.maskImage, ... + dic_postprocess.ops.imageHeightWidth(S.referenceImage)); + S.overlayExx = dic_postprocess.ops.makeStrainOverlay( ... + S.referenceImage, S.strain.exx, overlayMask, S.strain.roiMask, opts); + S.overlayEyy = dic_postprocess.ops.makeStrainOverlay( ... + S.referenceImage, S.strain.eyy, overlayMask, S.strain.roiMask, opts); + summaryMask = dic_postprocess.ops.summaryMaskForStrain(S.strain, overlayMask); + S.summaryTable = dic_postprocess.ops.summarizeStrain(S.strain, summaryMask); + dic_postprocess.ui.showImage(ui, S.overlayExx, 'EXX Overlay', 'exx'); + dic_postprocess.ui.showImage(ui, S.overlayEyy, 'EYY Overlay', 'eyy'); + resultTable.Data = dic_postprocess.view.summaryTableData(S.summaryTable); + refreshSummaryText(); + end + + function opts = overlayOptionsFromControls() + opts = struct(); + opts.alpha = edAlpha.Value; + opts.colorRange = [edMin.Value edMax.Value]; + opts.oversample = max(1, round(edOversample.Value)); + opts.sigmaSmooth = edSigma.Value; + opts.colormap = jet(256); + opts.exportResolution = round(edResolution.Value); + opts.brightness = edBrightness.Value; + opts.contrast = edContrast.Value; + opts.gamma = edGamma.Value; + opts.saturation = edSaturation.Value; + opts.rgbGain = [edRedGain.Value edGreenGain.Value edBlueGain.Value]; + end + + function refreshSummaryText() + lines = {}; + lines{end+1} = sprintf('DIC MAT: %s', dic_postprocess.view.displayPath(S.matPath)); + lines{end+1} = sprintf('Reference image: %s', dic_postprocess.view.displayPath(S.referencePath)); + lines{end+1} = sprintf('Mask image: %s', dic_postprocess.view.displayPath(S.maskPath)); + lines{end+1} = sprintf('Overlays: %s', ... + dic_postprocess.view.ternary(~isempty(S.overlayExx), ... + 'available', 'not generated')); + lines{end+1} = sprintf('Optical image: brightness %.3g, contrast %.3g, gamma %.3g, saturation %.3g', ... + edBrightness.Value, edContrast.Value, edGamma.Value, edSaturation.Value); + txtSummary.Value = lines; + end + + function addLog(msg) + labkit.ui.view.appendLog(ui, 'appLog', msg); + debugLog.append(msg); + end +end diff --git a/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m b/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m index 0069825..4ae2a6f 100644 --- a/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m +++ b/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m @@ -17,337 +17,11 @@ 'labkit_DICPostprocess_app returns at most the app figure handle.'); end - S = struct(); - S.matPath = ""; - S.referencePath = ""; - S.maskPath = ""; - S.strain = struct(); - S.referenceImage = []; - S.maskImage = []; - S.overlayExx = []; - S.overlayEyy = []; - S.summaryTable = table(); - - workbenchOpts = struct( ... - 'rightTitle', 'Strain Overlays', ... - 'rightGridSize', [2 1], ... - 'rightRowHeight', {{'1x', '1x'}}, ... - 'rightRowSpacing', 10); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... - {240, 230, 260, 120}, ... - struct('resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 90))), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {210, '1x'}, ... - struct('resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 90))), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'DIC Strain Postprocess', ... - 'position', [90 70 1450 880], ... - 'leftWidth', 390, ... - 'options', workbenchOpts)); - ui = dic_postprocess.ui.createRightAxesPair(ui, ... - 'EXX Overlay', 'EYY Overlay', false); - fig = ui.fig; - - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - filePanel = labkit.ui.view.section(layFA, 'Inputs', 1, [6 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - fileGrid = filePanel.grid; - - btnMat = uibutton(fileGrid, 'Text', 'Open DIC MAT', 'ButtonPushedFcn', @onOpenMat); - btnMat.Layout.Row = 1; - btnMat.Layout.Column = 1; - btnReference = uibutton(fileGrid, 'Text', 'Open reference image', 'ButtonPushedFcn', @onOpenReference); - btnReference.Layout.Row = 1; - btnReference.Layout.Column = 2; - btnMask = uibutton(fileGrid, 'Text', 'Open mask image', 'ButtonPushedFcn', @onOpenMask); - btnMask.Layout.Row = 2; - btnMask.Layout.Column = [1 2]; - - txtMat = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No MAT file loaded')); - txtMat.Layout.Row = 3; - txtMat.Layout.Column = [1 2]; - txtReference = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No reference image loaded')); - txtReference.Layout.Row = 4; - txtReference.Layout.Column = [1 2]; - txtMask = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No mask image loaded')); - txtMask.Layout.Row = 5; - txtMask.Layout.Column = [1 2]; - - btnGenerate = uibutton(fileGrid, 'Text', 'Generate overlays + summary', ... - 'ButtonPushedFcn', @onGenerate); - btnGenerate.Layout.Row = 6; - btnGenerate.Layout.Column = [1 2]; - - optionPanel = labkit.ui.view.section(layFA, 'Overlay Options', 2, [6 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}})); - optionGrid = optionPanel.grid; - - [~, edAlpha] = labkit.ui.view.form(optionGrid, struct('kind', 'spinner', 'label', 'Alpha:', 'value', 0.60, 'limits', [0 1], 'step', 0.05, 'callback', @onOptionsChanged)); - [~, edMin] = labkit.ui.view.form(optionGrid, struct('kind', 'spinner', 'label', 'Color min:', 'value', -0.15, 'step', 0.01, 'callback', @onOptionsChanged)); - [~, edMax] = labkit.ui.view.form(optionGrid, struct('kind', 'spinner', 'label', 'Color max:', 'value', 0.15, 'step', 0.01, 'callback', @onOptionsChanged)); - [~, edOversample] = labkit.ui.view.form(optionGrid, struct('kind', 'spinner', 'label', 'Oversample:', 'value', 6, 'limits', [1 20], 'step', 1, 'callback', @onOptionsChanged)); - [~, edSigma] = labkit.ui.view.form(optionGrid, struct('kind', 'spinner', 'label', 'Smooth sigma:', 'value', 0.8, 'limits', [0 Inf], 'step', 0.1, 'callback', @onOptionsChanged)); - [~, edResolution] = labkit.ui.view.form(optionGrid, struct('kind', 'spinner', 'label', 'Export DPI:', 'value', 1000, 'limits', [72 2400], 'step', 50)); - - imagePanel = labkit.ui.view.section(layFA, 'Optical Image Enhancement', 3, [7 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}})); - imageGrid = imagePanel.grid; - - [~, edBrightness] = labkit.ui.view.form(imageGrid, struct('kind', 'spinner', 'label', 'Brightness:', 'value', 0, 'limits', [-1 1], 'step', 0.05, 'callback', @onOptionsChanged)); - [~, edContrast] = labkit.ui.view.form(imageGrid, struct('kind', 'spinner', 'label', 'Contrast:', 'value', 1, 'limits', [0.05 5], 'step', 0.05, 'callback', @onOptionsChanged)); - [~, edGamma] = labkit.ui.view.form(imageGrid, struct('kind', 'spinner', 'label', 'Gamma:', 'value', 1, 'limits', [0.05 5], 'step', 0.05, 'callback', @onOptionsChanged)); - [~, edSaturation] = labkit.ui.view.form(imageGrid, struct('kind', 'spinner', 'label', 'Saturation:', 'value', 1, 'limits', [0 5], 'step', 0.05, 'callback', @onOptionsChanged)); - [~, edRedGain] = labkit.ui.view.form(imageGrid, struct('kind', 'spinner', 'label', 'Red gain:', 'value', 1, 'limits', [0 5], 'step', 0.05, 'callback', @onOptionsChanged)); - [~, edGreenGain] = labkit.ui.view.form(imageGrid, struct('kind', 'spinner', 'label', 'Green gain:', 'value', 1, 'limits', [0 5], 'step', 0.05, 'callback', @onOptionsChanged)); - [~, edBlueGain] = labkit.ui.view.form(imageGrid, struct('kind', 'spinner', 'label', 'Blue gain:', 'value', 1, 'limits', [0 5], 'step', 0.05, 'callback', @onOptionsChanged)); - - exportPanel = labkit.ui.view.section(layFA, 'Exports', 4, [3 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit'}}, 'columnWidth', {{'1x', '1x'}})); - exportGrid = exportPanel.grid; - btnSaveOverlays = uibutton(exportGrid, 'Text', 'Save overlay PNGs', ... - 'ButtonPushedFcn', @onSaveOverlays); - btnSaveOverlays.Layout.Row = 1; - btnSaveOverlays.Layout.Column = [1 2]; - btnExportSummary = uibutton(exportGrid, 'Text', 'Export summary CSV', ... - 'ButtonPushedFcn', @onExportSummary); - btnExportSummary.Layout.Row = 2; - btnExportSummary.Layout.Column = [1 2]; - btnExportColorbar = uibutton(exportGrid, 'Text', 'Export strain colorbar + levels', ... - 'ButtonPushedFcn', @onExportColorbar); - btnExportColorbar.Layout.Row = 3; - btnExportColorbar.Layout.Column = [1 2]; - - resultUi = labkit.ui.view.panel(laySR, 'table', 'ROI Strain Summary', 1, ... - {'Metric', 'EXX', 'EYY'}); - resultTable = resultUi.table; - - txtSummary = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(txtSummary, laySR, 2); - txtSummary.Value = {'No DIC result loaded.'}; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - txtLog = logUi.textArea; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('DIC postprocess debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - labkit.ui.view.draw(ui.topAxes, 'reset', 'EXX Overlay', true); - labkit.ui.view.draw(ui.bottomAxes, 'reset', 'EYY Overlay', true); - + fig = dic_postprocess.run(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenMat(~, ~) - [f, p] = uigetfile({'*.mat', 'MAT files (*.mat)'}, 'Select Ncorr DIC MAT file'); - if isequal(f, 0) - addLog('DIC MAT selection cancelled.'); - return; - end - S.matPath = string(fullfile(p, f)); - txtMat.Value = char(S.matPath); - addLog(sprintf('Selected DIC MAT: %s', S.matPath)); - refreshSummaryText(); - end - - function onOpenReference(~, ~) - filepath = dic_postprocess.io.chooseImageFile('Select reference image'); - if filepath == "" - addLog('Reference image selection cancelled.'); - return; - end - S.referencePath = filepath; - S.referenceImage = imread(filepath); - txtReference.Value = char(filepath); - addLog(sprintf('Loaded reference image: %s', filepath)); - refreshSummaryText(); - end - - function onOpenMask(~, ~) - filepath = dic_postprocess.io.chooseImageFile('Select mask image'); - if filepath == "" - addLog('Mask image selection cancelled.'); - return; - end - S.maskPath = filepath; - S.maskImage = imread(filepath); - txtMask.Value = char(filepath); - addLog(sprintf('Loaded mask image: %s', filepath)); - refreshSummaryText(); - end - - function onGenerate(~, ~) - if strlength(S.matPath) == 0 || isempty(S.referenceImage) || isempty(S.maskImage) - uialert(fig, 'Load the DIC MAT file, reference image, and mask image first.', ... - 'Missing inputs'); - return; - end - if edMax.Value <= edMin.Value - uialert(fig, 'Color max must be greater than color min.', 'Invalid color range'); - return; - end - - try - S.strain = dic_postprocess.io.loadNcorrStrain(char(S.matPath)); - renderOverlays(true); - addLog('Generated EXX/EYY overlays and ROI summary.'); - catch ME - uialert(fig, ME.message, 'DIC postprocess error'); - addLog(sprintf('Generate failed: %s', ME.message)); - end - end - - function onSaveOverlays(~, ~) - if isempty(S.overlayExx) || isempty(S.overlayEyy) - uialert(fig, 'Generate overlays before saving.', 'Save overlays'); - return; - end - - folder = uigetdir(pwd, 'Select folder for overlay PNGs'); - if isequal(folder, 0) - addLog('Save overlay PNGs cancelled.'); - return; - end - - tag = dic_postprocess.view.tagFromPath(char(S.matPath)); - opts = overlayOptionsFromControls(); - exxFile = fullfile(folder, sprintf('overlay_exx_%s.png', tag)); - eyyFile = fullfile(folder, sprintf('overlay_eyy_%s.png', tag)); - dic_postprocess.export.exportOverlayFigure(S.overlayExx, 'EXX', ... - opts.colorRange, opts.exportResolution, exxFile); - dic_postprocess.export.exportOverlayFigure(S.overlayEyy, 'EYY', ... - opts.colorRange, opts.exportResolution, eyyFile); - addLog(sprintf('Saved overlay PNGs: %s and %s', exxFile, eyyFile)); - end - - function onExportSummary(~, ~) - if isempty(S.summaryTable) || height(S.summaryTable) == 0 - uialert(fig, 'Generate a summary before exporting.', 'Export summary'); - return; - end - - [folder, name] = fileparts(char(S.matPath)); - defaultName = fullfile(folder, [name '_strain_summary.csv']); - [f, p] = uiputfile('*.csv', 'Save strain summary CSV', defaultName); - if isequal(f, 0) - addLog('Export summary cancelled.'); - return; - end - - out = fullfile(p, f); - writetable(S.summaryTable, out); - addLog(sprintf('Exported summary CSV: %s', out)); - end - - function onExportColorbar(~, ~) - if edMax.Value <= edMin.Value - uialert(fig, 'Color max must be greater than color min.', 'Invalid color range'); - return; - end - - [folder, name] = fileparts(char(S.matPath)); - if isempty(folder) - folder = pwd; - end - if isempty(name) - name = 'dic_strain'; - end - defaultName = fullfile(folder, [name '_strain_colorbar.png']); - [f, p] = uiputfile({'*.png', 'PNG image'}, 'Save strain colorbar', defaultName); - if isequal(f, 0) - addLog('Export strain colorbar cancelled.'); - return; - end - - opts = overlayOptionsFromControls(); - pngOut = fullfile(p, f); - [~, baseName] = fileparts(f); - csvOut = fullfile(p, [baseName '_levels.csv']); - dic_postprocess.export.exportStrainColorbar(opts, pngOut); - writetable(dic_postprocess.view.colorbarLevelsTable(opts), csvOut); - addLog(sprintf('Exported strain colorbar: %s and %s', pngOut, csvOut)); - end - - function onOptionsChanged(~, ~) - if isfield(S.strain, 'exx') && ~isempty(S.referenceImage) && ~isempty(S.maskImage) - try - renderOverlays(false); - catch ME - addLog(sprintf('Option update skipped: %s', ME.message)); - end - end - end - - function renderOverlays(showAlerts) - if edMax.Value <= edMin.Value - if showAlerts - uialert(fig, 'Color max must be greater than color min.', 'Invalid color range'); - end - return; - end - - opts = overlayOptionsFromControls(); - overlayMask = dic_postprocess.ops.imageMask(S.maskImage, ... - dic_postprocess.ops.imageHeightWidth(S.referenceImage)); - S.overlayExx = dic_postprocess.ops.makeStrainOverlay( ... - S.referenceImage, S.strain.exx, overlayMask, S.strain.roiMask, opts); - S.overlayEyy = dic_postprocess.ops.makeStrainOverlay( ... - S.referenceImage, S.strain.eyy, overlayMask, S.strain.roiMask, opts); - summaryMask = dic_postprocess.ops.summaryMaskForStrain(S.strain, overlayMask); - S.summaryTable = dic_postprocess.ops.summarizeStrain(S.strain, summaryMask); - dic_postprocess.ui.showImage(ui.topAxes, S.overlayExx, 'EXX Overlay'); - dic_postprocess.ui.showImage(ui.bottomAxes, S.overlayEyy, 'EYY Overlay'); - resultTable.Data = dic_postprocess.view.summaryTableData(S.summaryTable); - refreshSummaryText(); - end - - function opts = overlayOptionsFromControls() - opts = struct(); - opts.alpha = edAlpha.Value; - opts.colorRange = [edMin.Value edMax.Value]; - opts.oversample = max(1, round(edOversample.Value)); - opts.sigmaSmooth = edSigma.Value; - opts.colormap = jet(256); - opts.exportResolution = round(edResolution.Value); - opts.brightness = edBrightness.Value; - opts.contrast = edContrast.Value; - opts.gamma = edGamma.Value; - opts.saturation = edSaturation.Value; - opts.rgbGain = [edRedGain.Value edGreenGain.Value edBlueGain.Value]; - end - - function refreshSummaryText() - lines = {}; - lines{end+1} = sprintf('DIC MAT: %s', dic_postprocess.view.displayPath(S.matPath)); - lines{end+1} = sprintf('Reference image: %s', dic_postprocess.view.displayPath(S.referencePath)); - lines{end+1} = sprintf('Mask image: %s', dic_postprocess.view.displayPath(S.maskPath)); - lines{end+1} = sprintf('Overlays: %s', ... - dic_postprocess.view.ternary(~isempty(S.overlayExx), ... - 'available', 'not generated')); - lines{end+1} = sprintf('Optical image: brightness %.3g, contrast %.3g, gamma %.3g, saturation %.3g', ... - edBrightness.Value, edContrast.Value, edGamma.Value, edSaturation.Value); - txtSummary.Value = lines; - end - - function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); - debugLog.append(msg); - end end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/buildSpec.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/buildSpec.m new file mode 100644 index 0000000..9d154b6 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+ui/buildSpec.m @@ -0,0 +1,141 @@ +% Expected caller: dic_preprocess.ui.runApp. Input is a callback struct whose +% fields are app-owned callback handles. Output is a data-only UI 2.0 +% workbench spec for the DIC Preprocess app. +function spec = buildSpec(callbacks) + + previewItems = {'Current pair', 'Current moving image', ... + 'False-color overlay', 'Original pair', 'ROI mask'}; + boundaryItems = {'Curve', 'Straight lines'}; + + spec = labkit.ui.spec.app("dicPreprocessApp", ... + "DIC Image Preprocess", ... + "position", [80 60 1400 860], ... + "leftWidth", 370, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("imagesSection", "Images", { ... + labkit.ui.spec.actionGroup("imageActions", { ... + labkit.ui.spec.action("openReference", ... + "Open reference image", callbackValue(callbacks, ... + "openReference")), ... + labkit.ui.spec.action("openMoving", ... + "Open moving image", callbackValue(callbacks, ... + "openMoving"))}), ... + labkit.ui.spec.field("referencePath", "Reference image", ... + "kind", "readonly", ... + "value", "No reference image loaded"), ... + labkit.ui.spec.field("movingPath", "Moving image", ... + "kind", "readonly", ... + "value", "No moving image loaded"), ... + labkit.ui.spec.field("previewMode", "Preview:", ... + "kind", "dropdown", ... + "items", previewItems, ... + "value", previewItems{1}, ... + "onChange", callbackValue(callbacks, ... + "previewChanged"))}, ... + "height", 240), ... + labkit.ui.spec.section("registrationCrop", ... + "Registration + Crop", { ... + labkit.ui.spec.action("align", ... + "Select points + align", callbackValue(callbacks, ... + "align")), ... + labkit.ui.spec.action("autoAlign", ... + "Auto align current pair", callbackValue(callbacks, ... + "autoAlign")), ... + labkit.ui.spec.action("startCropRoi", ... + "Start/reset crop ROI", callbackValue(callbacks, ... + "startCropRoi")), ... + labkit.ui.spec.actionGroup("cropActions", { ... + labkit.ui.spec.action("applyCropRoi", ... + "Apply ROI crop", callbackValue(callbacks, ... + "applyCropRoi"), "enabled", false), ... + labkit.ui.spec.action("cancelCropRoi", ... + "Cancel ROI", callbackValue(callbacks, ... + "cancelCropRoi"), "enabled", false)}), ... + labkit.ui.spec.actionGroup("editActions", { ... + labkit.ui.spec.action("undoEdit", ... + "Undo align/crop", callbackValue(callbacks, ... + "undoEdit"), "enabled", false), ... + labkit.ui.spec.action("saveCurrentImages", ... + "Save current images", callbackValue(callbacks, ... + "saveCurrentImages"))}), ... + labkit.ui.spec.action("resetToOriginals", ... + "Reset to originals", callbackValue(callbacks, ... + "resetToOriginals"))}, ... + "height", 210), ... + labkit.ui.spec.section("maskRoi", "Mask ROI", { ... + labkit.ui.spec.action("startMaskEdit", ... + "Start ROI edit", callbackValue(callbacks, ... + "startMaskEdit")), ... + labkit.ui.spec.field("boundaryStyle", "Boundary:", ... + "kind", "dropdown", ... + "items", boundaryItems, ... + "value", boundaryItems{1}, ... + "enabled", false, ... + "onChange", callbackValue(callbacks, ... + "boundaryStyleChanged")), ... + labkit.ui.spec.actionGroup("maskPreviewActions", { ... + labkit.ui.spec.action("previewMaskRoi", ... + "Preview ROI mask", callbackValue(callbacks, ... + "previewMaskRoi"), "enabled", false), ... + labkit.ui.spec.action("addBoundaryToMask", ... + "Add to mask", callbackValue(callbacks, ... + "addBoundaryToMask"), "enabled", false)}), ... + labkit.ui.spec.actionGroup("maskEditActions", { ... + labkit.ui.spec.action("subtractBoundaryFromMask", ... + "Subtract from mask", callbackValue(callbacks, ... + "subtractBoundaryFromMask"), "enabled", false), ... + labkit.ui.spec.action("undoMaskAnchor", ... + "Undo point", callbackValue(callbacks, ... + "undoMaskAnchor"), "enabled", false)}), ... + labkit.ui.spec.actionGroup("maskHistoryActions", { ... + labkit.ui.spec.action("undoMaskEdit", ... + "Undo mask edit", callbackValue(callbacks, ... + "undoMaskEdit"), "enabled", false), ... + labkit.ui.spec.action("clearMaskBoundary", ... + "Clear boundary", callbackValue(callbacks, ... + "clearMaskBoundary"), "enabled", false)}), ... + labkit.ui.spec.action("clearMaskCanvas", ... + "Clear mask", callbackValue(callbacks, ... + "clearMaskCanvas"), "enabled", false), ... + labkit.ui.spec.action("saveMask", ... + "Save ROI mask", callbackValue(callbacks, ... + "saveMask"))}, ... + "height", 330), ... + labkit.ui.spec.section("workflowNotes", ... + "Workflow Notes", { ... + labkit.ui.spec.statusPanel("workflowNotesText", ... + "Workflow Notes", "value", { ... + '1. Load a reference image and a moving image.', ... + '2. Align or crop the current working pair in any order; each apply step can be undone.', ... + '3. False-color preview compares the current pair even before alignment.', ... + '4. Draw curve or straight-line ROI boundaries, add/subtract them on the mask canvas, then save the mask.'})}, ... + "height", 170)}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("summarySection", "Summary", { ... + labkit.ui.spec.statusPanel("summaryText", "Summary", ... + "value", {'No images loaded.'})}, ... + "height", 150), ... + labkit.ui.spec.section("detailsSection", "Details", { ... + labkit.ui.spec.statusPanel("detailsText", "Details", ... + "value", {'Alignment and crop details will appear here.'})})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log", ... + "value", {'Ready.'})})})}, ... + "workspace", labkit.ui.spec.workspace("imagePreview", ... + "Image Preview", { ... + labkit.ui.spec.previewArea("previewAxes", "Image Preview", ... + "layout", "stack", "count", 2, ... + "axisIds", {'reference', 'current'}, ... + "axisTitles", {'Reference', 'Current Preview'})}, ... + "rowSpacing", 10)); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/createLayout.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/createLayout.m deleted file mode 100644 index bd54420..0000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/createLayout.m +++ /dev/null @@ -1,187 +0,0 @@ -% Expected caller: DIC preprocess runner. Inputs are callback handles for app -% actions and preview scrolling. Outputs are the shell/UI handle struct and the -% app-specific control handle struct. Side effects: creates GUI controls. - -function [ui, controls] = createLayout(callbacks) -%CREATELAYOUT Build DIC preprocess shell, controls, and preview runtime. - - workbenchOpts = struct( ... - 'rightTitle', 'Image Preview', ... - 'rightGridSize', [2 1], ... - 'rightRowHeight', {{'1x', '1x'}}, ... - 'rightRowSpacing', 10); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... - {240, 210, 330, 170}, ... - struct('resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 80))), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {150, '1x'}, ... - struct('resizeOptions', struct('minTopHeight', 90, 'minBottomHeight', 90))), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'DIC Image Preprocess', ... - 'position', [80 60 1400 860], ... - 'leftWidth', 370, ... - 'options', workbenchOpts)); - ui = dic_preprocess.ui.createRightAxesPair(ui, ... - 'Reference', 'Current Preview', false); - ui.imageRuntime = labkit.ui.tool.createRuntime(ui.topAxes, ... - struct('figure', ui.fig, 'defaultScrollFcn', callbacks.scrollZoom)); - - filePanel = labkit.ui.view.section(ui.filesAnalysisGrid, ... - 'Images', 1, [4 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - fileGrid = filePanel.grid; - - controls.btnReference = uibutton(fileGrid, 'Text', 'Open reference image', ... - 'ButtonPushedFcn', callbacks.openReference); - controls.btnReference.Layout.Row = 1; - controls.btnReference.Layout.Column = 1; - controls.btnMoving = uibutton(fileGrid, 'Text', 'Open moving image', ... - 'ButtonPushedFcn', callbacks.openMoving); - controls.btnMoving.Layout.Row = 1; - controls.btnMoving.Layout.Column = 2; - - controls.txtReference = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No reference image loaded')); - controls.txtReference.Layout.Row = 2; - controls.txtReference.Layout.Column = [1 2]; - controls.txtMoving = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No moving image loaded')); - controls.txtMoving.Layout.Row = 3; - controls.txtMoving.Layout.Column = [1 2]; - - [controls.lblPreview, controls.ddPreview] = labkit.ui.view.form( ... - fileGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Preview:', ... - 'items', {{'Current pair', 'Current moving image', ... - 'False-color overlay', 'Original pair', 'ROI mask'}}, ... - 'value', 'Current pair', ... - 'callback', callbacks.previewChanged)); - controls.lblPreview.Layout.Row = 4; - controls.lblPreview.Layout.Column = 1; - controls.ddPreview.Layout.Row = 4; - controls.ddPreview.Layout.Column = 2; - - actionPanel = labkit.ui.view.section(ui.filesAnalysisGrid, ... - 'Registration + Crop', 2, [6 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - actionGrid = actionPanel.grid; - controls.btnAlign = uibutton(actionGrid, 'Text', 'Select points + align', ... - 'ButtonPushedFcn', callbacks.align); - controls.btnAlign.Layout.Row = 1; - controls.btnAlign.Layout.Column = [1 2]; - controls.btnAutoAlign = uibutton(actionGrid, 'Text', 'Auto align current pair', ... - 'ButtonPushedFcn', callbacks.autoAlign); - controls.btnAutoAlign.Layout.Row = 2; - controls.btnAutoAlign.Layout.Column = [1 2]; - controls.btnCrop = uibutton(actionGrid, 'Text', 'Start/reset crop ROI', ... - 'ButtonPushedFcn', callbacks.startCropRoi); - controls.btnCrop.Layout.Row = 3; - controls.btnCrop.Layout.Column = [1 2]; - controls.btnApplyCrop = uibutton(actionGrid, 'Text', 'Apply ROI crop', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.applyCropRoi); - controls.btnApplyCrop.Layout.Row = 4; - controls.btnApplyCrop.Layout.Column = 1; - controls.btnCancelCrop = uibutton(actionGrid, 'Text', 'Cancel ROI', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.cancelCropRoi); - controls.btnCancelCrop.Layout.Row = 4; - controls.btnCancelCrop.Layout.Column = 2; - controls.btnUndoEdit = uibutton(actionGrid, 'Text', 'Undo align/crop', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.undoEdit); - controls.btnUndoEdit.Layout.Row = 5; - controls.btnUndoEdit.Layout.Column = 1; - controls.btnSaveCurrent = uibutton(actionGrid, 'Text', 'Save current images', ... - 'ButtonPushedFcn', callbacks.saveCurrentImages); - controls.btnSaveCurrent.Layout.Row = 5; - controls.btnSaveCurrent.Layout.Column = 2; - controls.btnResetCurrent = uibutton(actionGrid, 'Text', 'Reset to originals', ... - 'ButtonPushedFcn', callbacks.resetToOriginals); - controls.btnResetCurrent.Layout.Row = 6; - controls.btnResetCurrent.Layout.Column = [1 2]; - - maskPanel = labkit.ui.view.section(ui.filesAnalysisGrid, ... - 'Mask ROI', 3, [7 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - maskGrid = maskPanel.grid; - controls.btnStartMask = uibutton(maskGrid, 'Text', 'Start ROI edit', ... - 'ButtonPushedFcn', callbacks.startMaskEdit); - controls.btnStartMask.Layout.Row = 1; - controls.btnStartMask.Layout.Column = [1 2]; - [controls.lblBoundaryStyle, controls.ddBoundaryStyle] = labkit.ui.view.form( ... - maskGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Boundary:', ... - 'items', {{'Curve', 'Straight lines'}}, ... - 'value', 'Curve', ... - 'callback', callbacks.boundaryStyleChanged)); - controls.lblBoundaryStyle.Layout.Row = 2; - controls.lblBoundaryStyle.Layout.Column = 1; - controls.ddBoundaryStyle.Layout.Row = 2; - controls.ddBoundaryStyle.Layout.Column = 2; - controls.ddBoundaryStyle.Enable = 'off'; - controls.btnPreviewMask = uibutton(maskGrid, 'Text', 'Preview ROI mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.previewMaskRoi); - controls.btnPreviewMask.Layout.Row = 3; - controls.btnPreviewMask.Layout.Column = 1; - controls.btnUnionMask = uibutton(maskGrid, 'Text', 'Add to mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.addBoundaryToMask); - controls.btnUnionMask.Layout.Row = 3; - controls.btnUnionMask.Layout.Column = 2; - controls.btnSubtractMask = uibutton(maskGrid, 'Text', 'Subtract from mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.subtractBoundaryFromMask); - controls.btnSubtractMask.Layout.Row = 4; - controls.btnSubtractMask.Layout.Column = 1; - controls.btnUndoMask = uibutton(maskGrid, 'Text', 'Undo point', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.undoMaskAnchor); - controls.btnUndoMask.Layout.Row = 4; - controls.btnUndoMask.Layout.Column = 2; - controls.btnUndoMaskEdit = uibutton(maskGrid, 'Text', 'Undo mask edit', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.undoMaskEdit); - controls.btnUndoMaskEdit.Layout.Row = 5; - controls.btnUndoMaskEdit.Layout.Column = 1; - controls.btnClearBoundary = uibutton(maskGrid, 'Text', 'Clear boundary', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.clearMaskBoundary); - controls.btnClearBoundary.Layout.Row = 5; - controls.btnClearBoundary.Layout.Column = 2; - controls.btnClearMask = uibutton(maskGrid, 'Text', 'Clear mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.clearMaskCanvas); - controls.btnClearMask.Layout.Row = 6; - controls.btnClearMask.Layout.Column = [1 2]; - controls.btnSaveMask = uibutton(maskGrid, 'Text', 'Save ROI mask', ... - 'ButtonPushedFcn', callbacks.saveMask); - controls.btnSaveMask.Layout.Row = 7; - controls.btnSaveMask.Layout.Column = [1 2]; - - labkit.ui.view.panel(ui.filesAnalysisGrid, 'text', 'Workflow Notes', 4, { ... - '1. Load a reference image and a moving image.', ... - '2. Align or crop the current working pair in any order; each apply step can be undone.', ... - '3. False-color preview compares the current pair even before alignment.', ... - '4. Draw curve or straight-line ROI boundaries, add/subtract them on the mask canvas, then save the mask.'}); - - ui.txtSummary = uitextarea(ui.summaryResultsGrid, 'Editable', 'off'); - ui.txtSummary.Layout.Row = 1; - ui.txtSummary.Value = {'No images loaded.'}; - ui.txtDetails = uitextarea(ui.summaryResultsGrid, 'Editable', 'off'); - labkit.ui.view.place(ui.txtDetails, ui.summaryResultsGrid, 2); - ui.txtDetails.Value = {'Alignment and crop details will appear here.'}; - - logUi = labkit.ui.view.panel(ui.logGrid, 'log', 1, {'Ready.'}); - ui.txtLog = logUi.textArea; -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/createRightAxesPair.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/createRightAxesPair.m deleted file mode 100644 index 7948db8..0000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/createRightAxesPair.m +++ /dev/null @@ -1,46 +0,0 @@ -% App-owned DIC preprocess preview layout helper. Expected caller: -% dic_preprocess.ui.createLayout. Inputs are the shell UI struct, axes titles, -% and whether plot-control panels are needed. Output is the UI struct with -% top/bottom axes and panel fields. Side effects are limited to creating axes -% and optional panels on the shell right grid. -function ui = createRightAxesPair(ui, topTitle, bottomTitle, showControls) -%CREATERIGHTAXESPAIR Create DIC preprocess preview axes. - - if showControls - ui.topControlsPanel = uipanel(ui.rightGrid, 'Title', topTitle); - ui.topControlsPanel.Layout.Row = 1; - ui.topAxes = createOneAxes(ui.rightGrid, 2, topTitle); - - ui.bottomControlsPanel = uipanel(ui.rightGrid, 'Title', bottomTitle); - ui.bottomControlsPanel.Layout.Row = 3; - ui.bottomAxes = createOneAxes(ui.rightGrid, 4, bottomTitle); - else - ui.topControlsPanel = []; - ui.bottomControlsPanel = []; - ui.topAxes = createOneAxes(ui.rightGrid, 1, topTitle); - ui.bottomAxes = createOneAxes(ui.rightGrid, 2, bottomTitle); - end -end - -function ax = createOneAxes(parent, row, titleText) - ax = uiaxes(parent); - ax.Layout.Row = row; - title(ax, titleText); - labkit.ui.view.draw(ax, 'popout'); - disableAxesInteractivity(ax); -end - -function disableAxesInteractivity(ax) - try - disableDefaultInteractivity(ax); - catch - end - try - ax.Interactions = []; - catch - end - try - ax.Toolbar.Visible = 'off'; - catch - end -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawMaskCanvas.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawMaskCanvas.m index 22bc728..cf30e2e 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawMaskCanvas.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawMaskCanvas.m @@ -8,6 +8,6 @@ function drawMaskCanvas(ui, referenceImage, maskImage, titleText) if isempty(maskImage) maskImage = zeros(size(referenceImage, 1), size(referenceImage, 2), 'uint8'); end - dic_preprocess.ui.showImage(ui.bottomAxes, ... + dic_preprocess.ui.showImage(ui, 'current', ... dic_preprocess.ops.maskRgb(maskImage), titleText); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawPreview.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawPreview.m index 4dd306d..c118a27 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawPreview.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+ui/drawPreview.m @@ -5,13 +5,14 @@ function drawPreview(ui, request) %DRAWPREVIEW Render a prepared DIC preprocess preview request. - labkit.ui.view.draw(ui.topAxes, 'reset', 'Reference', true); - labkit.ui.view.draw(ui.bottomAxes, 'reset', 'Current Preview', true); + labkit.ui.view.resetAxes(ui, 'previewAxes', 'Reference', true, 'reference'); + labkit.ui.view.resetAxes(ui, 'previewAxes', 'Current Preview', true, 'current'); if ~isempty(request.topImage) - dic_preprocess.ui.showImage(ui.topAxes, request.topImage, request.topTitle); + dic_preprocess.ui.showImage(ui, 'reference', ... + request.topImage, request.topTitle); end if ~isempty(request.bottomImage) - dic_preprocess.ui.showImage(ui.bottomAxes, ... + dic_preprocess.ui.showImage(ui, 'current', ... request.bottomImage, request.bottomTitle); end end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/mapControlHandles.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/mapControlHandles.m new file mode 100644 index 0000000..0c0d1db --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+ui/mapControlHandles.m @@ -0,0 +1,31 @@ +% Expected caller: DIC preprocess runner after labkit.ui.app.create. Input is +% the UI 2.0 registry. Output is the app's legacy-named control handle struct +% used by existing app-owned interaction helpers. Side effects: none. +function controls = mapControlHandles(ui) +%MAPCONTROLHANDLES Map UI 2.0 adapters to DIC preprocess control handles. + + controls = struct(); + controls.btnReference = ui.controls.openReference.button; + controls.btnMoving = ui.controls.openMoving.button; + controls.txtReference = ui.controls.referencePath.valueHandle; + controls.txtMoving = ui.controls.movingPath.valueHandle; + controls.ddPreview = ui.controls.previewMode.valueHandle; + controls.btnAlign = ui.controls.align.button; + controls.btnAutoAlign = ui.controls.autoAlign.button; + controls.btnCrop = ui.controls.startCropRoi.button; + controls.btnApplyCrop = ui.controls.applyCropRoi.button; + controls.btnCancelCrop = ui.controls.cancelCropRoi.button; + controls.btnUndoEdit = ui.controls.undoEdit.button; + controls.btnSaveCurrent = ui.controls.saveCurrentImages.button; + controls.btnResetCurrent = ui.controls.resetToOriginals.button; + controls.btnStartMask = ui.controls.startMaskEdit.button; + controls.ddBoundaryStyle = ui.controls.boundaryStyle.valueHandle; + controls.btnPreviewMask = ui.controls.previewMaskRoi.button; + controls.btnUnionMask = ui.controls.addBoundaryToMask.button; + controls.btnSubtractMask = ui.controls.subtractBoundaryFromMask.button; + controls.btnUndoMask = ui.controls.undoMaskAnchor.button; + controls.btnUndoMaskEdit = ui.controls.undoMaskEdit.button; + controls.btnClearBoundary = ui.controls.clearMaskBoundary.button; + controls.btnClearMask = ui.controls.clearMaskCanvas.button; + controls.btnSaveMask = ui.controls.saveMask.button; +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/showImage.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/showImage.m index af94d06..657c159 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/showImage.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+ui/showImage.m @@ -1,8 +1,10 @@ -% Expected caller: DIC preprocess runner. Inputs are a target axes, image data, -% and title. Output is the drawn image handle. Side effect: updates the axes. +% Expected caller: DIC preprocess runner. Inputs are the app UI registry, a +% preview axis id, image data, and title. Output is the drawn image handle. +% Side effect: updates the requested preview axis. -function hImage = showImage(ax, imageData, titleText) +function hImage = showImage(ui, axisId, imageData, titleText) %SHOWIMAGE Draw a DIC preprocess preview image in an axes. - hImage = labkit.ui.view.draw(ax, 'image', imageData, titleText); + hImage = labkit.ui.view.drawImage(ui, 'previewAxes', imageData, ... + "title", titleText, "axis", axisId); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/startMaskEdit.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/startMaskEdit.m index 91a5714..cbb6a69 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/startMaskEdit.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+ui/startMaskEdit.m @@ -6,11 +6,11 @@ function editor = startMaskEdit(ui, imageRuntime, referenceImage, points, boundaryStyle, changedFcn) %STARTMASKEDIT Start the DIC preprocess ROI mask anchor editor. - labkit.ui.view.draw(ui.topAxes, 'reset', 'Reference', true); - labkit.ui.view.draw(ui.bottomAxes, 'reset', 'Current Preview', true); - hTopImage = dic_preprocess.ui.showImage(ui.topAxes, ... + labkit.ui.view.resetAxes(ui, 'previewAxes', 'Reference', true, 'reference'); + labkit.ui.view.resetAxes(ui, 'previewAxes', 'Current Preview', true, 'current'); + hTopImage = dic_preprocess.ui.showImage(ui, 'reference', ... referenceImage, 'Current reference'); - dic_preprocess.ui.showImage(ui.bottomAxes, ... + dic_preprocess.ui.showImage(ui, 'current', ... zeros(size(referenceImage, 1), size(referenceImage, 2), 3, 'uint8'), ... 'ROI mask preview'); editor = labkit.ui.tool.anchorEditor(imageRuntime, size(referenceImage), ... diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/runApp.m b/apps/dic/dic_preprocess/+dic_preprocess/run.m similarity index 96% rename from apps/dic/dic_preprocess/+dic_preprocess/+ui/runApp.m rename to apps/dic/dic_preprocess/+dic_preprocess/run.m index 54ef2fa..beceb70 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/runApp.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/run.m @@ -2,12 +2,11 @@ % Input is the debug context prepared by the public launcher. Output is the app % figure. Side effects are GUI creation, user-driven file I/O, and debug trace % attachment exactly as in the original entrypoint body. -function fig = runApp(debugLog) -%RUNAPP Build and run the DIC preprocess app body. +function fig = run(debugLog) +%RUN Build and run the DIC preprocess app body. S = dic_preprocess.state.initialState(); callbacks = struct( ... - 'scrollZoom', @onPreviewScrollZoom, ... 'openReference', @onOpenReference, ... 'openMoving', @onOpenMoving, ... 'previewChanged', @(~,~) refreshPreview(), ... @@ -29,22 +28,25 @@ 'clearMaskBoundary', @onClearMaskBoundary, ... 'clearMaskCanvas', @onClearMaskCanvas, ... 'saveMask', @onSaveMask); - [ui, controls] = dic_preprocess.ui.createLayout(callbacks); + spec = dic_preprocess.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); fig = ui.fig; - imageRuntime = ui.imageRuntime; + ui.topAxes = ui.controls.previewAxes.axesById.reference; + ui.bottomAxes = ui.controls.previewAxes.axesById.current; + imageRuntime = labkit.ui.tool.createRuntime(ui.topAxes, ... + struct('figure', fig, 'defaultScrollFcn', @onPreviewScrollZoom)); + ui.imageRuntime = imageRuntime; + controls = dic_preprocess.ui.mapControlHandles(ui); txtReference = controls.txtReference; txtMoving = controls.txtMoving; - txtSummary = ui.txtSummary; - txtDetails = ui.txtDetails; - txtLog = ui.txtLog; + txtSummary = ui.controls.summaryText.textArea; + txtDetails = ui.controls.detailsText.textArea; ddPreview = controls.ddPreview; ddBoundaryStyle = controls.ddBoundaryStyle; btnApplyCrop = controls.btnApplyCrop; btnCancelCrop = controls.btnCancelCrop; if debugLog.enabled - debugLog.attachTextLog(txtLog); debugLog.trace('DIC preprocess debug trace enabled.'); - debugLog.instrumentFigure(fig); end refreshPreview(); @@ -407,7 +409,7 @@ function onSaveMask(~, ~) [boundaryMask, ok] = currentBoundaryMask(showAlert); if ok ddPreview.Value = 'ROI mask'; - dic_preprocess.ui.showImage(ui.bottomAxes, ... + dic_preprocess.ui.showImage(ui, 'current', ... dic_preprocess.ops.maskRgb(boundaryMask), 'ROI boundary preview'); updateMaskCurveGraphics(); addLog(sprintf('Previewed %s ROI boundary with %d anchors.', ... @@ -487,7 +489,7 @@ function clearDerivedStateAndMaskEditor() end function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); + labkit.ui.view.appendLog(ui, 'appLog', msg); debugLog.append(msg); end diff --git a/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m b/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m index f5c624f..ad3124c 100644 --- a/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m +++ b/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m @@ -17,7 +17,7 @@ 'labkit_DICPreprocess_app returns at most the app figure handle.'); end - fig = dic_preprocess.ui.runApp(debugLog); + fig = dic_preprocess.run(debugLog); if nargout >= 1 varargout{1} = fig; end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+ui/buildSpec.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+ui/buildSpec.m new file mode 100644 index 0000000..05c15b6 --- /dev/null +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+ui/buildSpec.m @@ -0,0 +1,81 @@ +% Expected caller: chrono_overlay.ui.runApp. Input is a callback struct whose +% fields are app-owned callback handles. Output is a data-only UI 2.0 workbench +% spec for the Chrono Overlay app. +function spec = buildSpec(callbacks) + + spec = labkit.ui.spec.app("chronoOverlay", ... + "Gamry Multi-DTA Plot Export GUI", ... + "position", [80 60 1480 900], ... + "leftWidth", 340, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("filesSection", "Files", { ... + labkit.ui.spec.pathPanel("files", "Files", ... + "mode", "multiFile", ... + "selectionMode", "multiple", ... + "chooseLabel", "Open DTA file(s)", ... + "clearLabel", "Clear all", ... + "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... + "status", "No files loaded", ... + "onChoose", callbackValue(callbacks, "openFilesChosen"), ... + "onClear", callbackValue(callbacks, "clearAll"), ... + "onSelectionChange", callbackValue(callbacks, "selectionChanged")), ... + labkit.ui.spec.actionGroup("fileActions", { ... + labkit.ui.spec.action("openFolder", ... + "Open folder recursively", callbackValue(callbacks, "openFolder")), ... + labkit.ui.spec.action("removeSelected", ... + "Remove selected", callbackValue(callbacks, "removeSelected")), ... + labkit.ui.spec.action("exportCurves", ... + "Export curves CSV", callbackValue(callbacks, "exportCSV"))})}), ... + labkit.ui.spec.section("plotOptions", "Plot Options", { ... + labkit.ui.spec.field("xAxis", "X axis:", ... + "kind", "dropdown", ... + "items", {'Time (s)', 'Time (ms)', 'Sample #'}, ... + "value", "Time (s)", ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("lineWidth", "Line width:", ... + "kind", "spinner", ... + "value", 1.3, ... + "limits", [0.1 10], ... + "step", 0.1, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("showLegend", ... + "Show file-name legend", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("showGrid", "Show grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged"))})}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("usageSection", "Usage", { ... + labkit.ui.spec.statusPanel("usage", "Usage", ... + "value", { ... + 'Usage:', ... + '1. Open multiple .DTA files.', ... + '2. Curves are aligned to the center of the blank time between cathodic and anodic phases.', ... + '3. Voltage and current curves will be overlaid.', ... + '4. Export CSV columns as: TimeGapCenterAligned_s, V_*, I_*.', ... + '5. If files have different time grids, export uses a merged aligned-time axis with interpolation.'})})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log")})})}, ... + "workspace", labkit.ui.spec.workspace("plots", "Overlay Plots", { ... + labkit.ui.spec.previewArea("overlayPlots", "Overlay Plots", ... + "layout", "stack", ... + "count", 2, ... + "axisIds", {'voltage', 'current'}, ... + "axisTitles", {'Voltage', 'Current'}, ... + "xLabels", {'Time (s)', 'Time (s)'}, ... + "yLabels", {'Vf (V)', 'Im (A)'})}, ... + "rowSpacing", 10)); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+ui/runApp.m b/apps/electrochem/chrono_overlay/+chrono_overlay/run.m similarity index 56% rename from apps/electrochem/chrono_overlay/+chrono_overlay/+ui/runApp.m rename to apps/electrochem/chrono_overlay/+chrono_overlay/run.m index 8fe2762..bdbaefa 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+ui/runApp.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/run.m @@ -2,118 +2,42 @@ % Input is the debug context prepared by the public launcher. Output is the app % figure. Side effects are GUI creation, user-driven file I/O, exports, % plotting, and debug trace attachment exactly as in the original entrypoint body. -function fig = runApp(debugLog) +function fig = run(debugLog) %RUNCHRONOOVERLAYAPP Build and run the app body. S = struct(); S.session = labkit.dta.makeSession('chrono_overlay'); S.items = S.session.items; - workbenchOpts = struct(); - workbenchOpts.rightTitle = 'Overlay Plots'; - workbenchOpts.rightGridSize = [2 1]; - workbenchOpts.rightRowHeight = {'1x', '1x'}; - workbenchOpts.rightRowSpacing = 10; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry Multi-DTA Plot Export GUI', ... - 'position', [80 60 1480 900], ... - 'leftWidth', 340, ... - 'options', workbenchOpts)); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - right = ui.rightGrid; - - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onRemoveSelected = @onRemoveSelected; - fileCallbacks.onClearAll = @onClearAll; - fileCallbacks.onExport = @onExportCSV; - fileCallbacks.onSelectFile = @(~,~) refreshPlots(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'removeSelected', 'Remove selected', ... - 'clearAll', 'Clear all', ... - 'export', 'Export curves CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks, ... - struct('showRemoveSelected', true, 'multiselect', 'on')); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - plotOptionsUi = labkit.ui.view.section(layFA, 'Plot Options', 2, [4 2]); - gp = plotOptionsUi.grid; - - [~, ddXAxis] = labkit.ui.view.form(gp, struct( ... - 'kind', 'dropdown', ... - 'label', 'X axis:', ... - 'items', {{'Time (s)', 'Time (ms)', 'Sample #'}}, ... - 'value', 'Time (s)', ... - 'callback', @(~,~) refreshPlots())); - - [~, edLineWidth] = labkit.ui.view.form(gp, struct( ... - 'kind', 'spinner', ... - 'label', 'Line width:', ... - 'value', 1.3, ... - 'limits', [0.1 10], ... - 'step', 0.1, ... - 'callback', @(~,~) refreshPlots())); - - cbLegend = uicheckbox(gp, ... - 'Text', 'Show file-name legend', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - cbLegend.Layout.Row = 3; - cbLegend.Layout.Column = [1 2]; - - cbGrid = uicheckbox(gp, ... - 'Text', 'Show grid', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - cbGrid.Layout.Row = 4; - cbGrid.Layout.Column = [1 2]; - - infoUi = labkit.ui.view.panel(laySR, 'text', 'Usage', 1, { ... - 'Usage:', ... - '1. Open multiple .DTA files.', ... - '2. Curves are aligned to the center of the blank time between cathodic and anodic phases.', ... - '3. Voltage and current curves will be overlaid.', ... - '4. Export CSV columns as: TimeGapCenterAligned_s, V_*, I_*.', ... - '5. If files have different time grids, export uses a merged aligned-time axis with interpolation.' ... - }); - txtInfo = infoUi.textArea; - - logUi = labkit.ui.view.panel(layLog, 'log', 1); - txtLog = logUi.textArea; + callbacks = struct( ... + "openFilesChosen", @onOpenFilesChosen, ... + "openFolder", @onOpenFolder, ... + "removeSelected", @onRemoveSelected, ... + "clearAll", @onClearAll, ... + "exportCSV", @onExportCSV, ... + "selectionChanged", @(~,~) refreshPlots(), ... + "plotOptionsChanged", @(~,~) refreshPlots()); + spec = chrono_overlay.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + fig = ui.figure; + lbFiles = ui.controls.files.listbox; + txtLoaded = ui.controls.files.status; + ddXAxis = ui.controls.xAxis.valueHandle; + edLineWidth = ui.controls.lineWidth.valueHandle; + cbLegend = ui.controls.showLegend.valueHandle; + cbGrid = ui.controls.showGrid.valueHandle; + axV = ui.controls.overlayPlots.axesById.voltage; + axI = ui.controls.overlayPlots.axesById.current; if debugLog.enabled - debugLog.attachTextLog(txtLog); debugLog.trace('Chrono overlay debug trace enabled.'); - debugLog.instrumentFigure(fig); end - - axV = labkit.ui.view.axes(right, 1, 'Voltage', 'Time (s)', 'Vf (V)'); - axI = labkit.ui.view.axes(right, 2, 'Current', 'Time (s)', 'Im (A)'); %% App callbacks, session actions, refresh, and export - function onOpenFiles(~, ~) - [f, p] = uigetfile( ... - {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - 'Select one or more Gamry DTA files', ... - 'MultiSelect', 'on'); - if isequal(f, 0) + function onOpenFilesChosen(~, event) + if isempty(event.paths) addLog('Open cancelled.'); return; end - - if ischar(f) || isstring(f) - f = {char(f)}; - end - - filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); - loadFiles(filepaths); + loadFiles(event.paths); end function onOpenFolder(~, ~) @@ -198,11 +122,11 @@ function onClearAll(~, ~) function refreshFileList() if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listItems', {}); + labkit.ui.view.setListItems(ui, 'files', {}); txtLoaded.Value = 'No files loaded'; return; end - labkit.ui.view.update(lbFiles, 'listItems', {S.items.name}); + labkit.ui.view.setListItems(ui, 'files', {S.items.name}); txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); end @@ -254,7 +178,7 @@ function onExportCSV(~, ~) end function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); + labkit.ui.view.appendLog(ui, 'appLog', msg); debugLog.append(msg); end end diff --git a/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m b/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m index 3c12b9b..d51becf 100644 --- a/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m +++ b/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m @@ -17,7 +17,7 @@ error('labkit_ChronoOverlay_app:TooManyOutputs', 'labkit_ChronoOverlay_app returns at most the app figure handle.'); end - fig = chrono_overlay.ui.runApp(debugLog); + fig = chrono_overlay.run(debugLog); if nargout >= 1 varargout{1} = fig; end diff --git a/apps/electrochem/cic/+cic/+ui/buildControls.m b/apps/electrochem/cic/+cic/+ui/buildControls.m deleted file mode 100644 index 4ad98af..0000000 --- a/apps/electrochem/cic/+cic/+ui/buildControls.m +++ /dev/null @@ -1,174 +0,0 @@ -% Build the CIC app's static shell and controls. Expected caller is -% cic.ui.runApp. Input callbacks is a struct of runner-owned callback handles. -% Output is a struct of UI handles and plot defaults used by the runner. -% Side effects are limited to creating MATLAB UI components in a new figure. -function C = buildControls(callbacks) -%BUILDCONTROLS Create CIC app controls. - - C = struct(); - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry CIC GUI (Voltage Transient)', ... - 'position', [40 30 1680 980], ... - 'leftWidth', 430, ... - 'options', struct( ... - 'rightTitle', 'Plots', ... - 'rightGridSize', [4 1], ... - 'rightRowHeight', {{'fit', '1x', 'fit', '1x'}}, ... - 'rightRowSpacing', 10))); - ui = cic.ui.createRightAxesPair(ui, 'Top Plot', 'Bottom Plot', true); - C.fig = ui.fig; - - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'clearAll', 'Clear all', ... - 'export', 'Export results CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(ui.filesAnalysisGrid, 'files', fileLabels, callbacks); - C.lbFiles = fileUi.listbox; - C.txtLoaded = fileUi.loadedText; - C.loadedText = fileLabels.loadedText; - - settingsUi = labkit.ui.view.section(ui.filesAnalysisGrid, ... - 'Analysis Settings', 2, [9 2]); - gs = settingsUi.grid; - - uilabel(gs, 'Text', 'Window preset:', 'HorizontalAlignment', 'right'); - C.ddPreset = uidropdown(gs, ... - 'Items', {'Pt (-0.6 to 0.8 V)', 'PEDOT:PSS (-0.9 to 0.6 V)', 'Custom'}, ... - 'Value', 'Pt (-0.6 to 0.8 V)', ... - 'ValueChangedFcn', callbacks.onPresetChanged); - C.ddPreset.Layout.Row = 1; C.ddPreset.Layout.Column = 2; - - [lblCathLim, C.edCathLim] = labkit.ui.view.form(gs, struct( ... - 'kind', 'spinner', ... - 'label', 'Cathodic limit (V):', ... - 'value', -0.6, ... - 'limits', [-10 10], ... - 'step', 0.01, ... - 'valueDisplayFormat', '%.6g', ... - 'callback', callbacks.onAnalyzeCurrentFile)); - lblCathLim.Layout.Row = 2; lblCathLim.Layout.Column = 1; - C.edCathLim.Layout.Row = 2; C.edCathLim.Layout.Column = 2; - - [lblAnodLim, C.edAnodLim] = labkit.ui.view.form(gs, struct( ... - 'kind', 'spinner', ... - 'label', 'Anodic limit (V):', ... - 'value', 0.8, ... - 'limits', [-10 10], ... - 'step', 0.01, ... - 'valueDisplayFormat', '%.6g', ... - 'callback', callbacks.onAnalyzeCurrentFile)); - lblAnodLim.Layout.Row = 3; lblAnodLim.Layout.Column = 1; - C.edAnodLim.Layout.Row = 3; C.edAnodLim.Layout.Column = 2; - - [lblDelayUs, C.edDelayUs] = labkit.ui.view.form(gs, struct( ... - 'kind', 'spinner', ... - 'label', 'Sample delay after pulse end:', ... - 'value', 10, ... - 'limits', [0 inf], ... - 'step', 1, ... - 'valueDisplayFormat', '%.6g', ... - 'callback', callbacks.onAnalyzeCurrentFile)); - lblDelayUs.Layout.Row = 4; lblDelayUs.Layout.Column = 1; - C.edDelayUs.Layout.Row = 4; C.edDelayUs.Layout.Column = 2; - - uilabel(gs, 'Text', 'Area override (cm^2):', 'HorizontalAlignment', 'right'); - C.edArea = uieditfield(gs, 'text', 'Value', '', ... - 'ValueChangedFcn', callbacks.onAnalyzeCurrentFile); - C.edArea.Layout.Row = 5; C.edArea.Layout.Column = 2; - - uilabel(gs, 'Text', 'Pulse detection:', 'HorizontalAlignment', 'right'); - C.ddPulseMode = uidropdown(gs, ... - 'Items', {'Metadata first, then auto', 'Metadata only', 'Auto from Im only'}, ... - 'Value', 'Metadata first, then auto', ... - 'ValueChangedFcn', callbacks.onAnalyzeCurrentFile); - C.ddPulseMode.Layout.Row = 6; C.ddPulseMode.Layout.Column = 2; - - uilabel(gs, 'Text', 'CIC summary mode:', 'HorizontalAlignment', 'right'); - C.ddCICMode = uidropdown(gs, ... - 'Items', {'Cathodic phase', 'Anodic phase', 'Total biphasic'}, ... - 'Value', 'Total biphasic', ... - 'ValueChangedFcn', callbacks.onRefreshResultsSummary); - C.ddCICMode.Layout.Row = 7; C.ddCICMode.Layout.Column = 2; - - uilabel(gs, 'Text', 'CIC unit:', 'HorizontalAlignment', 'right'); - C.ddCICUnit = uidropdown(gs, ... - 'Items', {'mC/cm^2', 'uC/cm^2'}, ... - 'Value', 'mC/cm^2', ... - 'ValueChangedFcn', callbacks.onRefreshCICUnitDisplays); - C.ddCICUnit.Layout.Row = 8; C.ddCICUnit.Layout.Column = 2; - - C.cbUseMeasuredCurrent = uicheckbox(gs, ... - 'Text', 'Use measured Im integration for charge (recommended)', ... - 'Value', true, 'ValueChangedFcn', callbacks.onAnalyzeCurrentFile); - C.cbUseMeasuredCurrent.Layout.Row = 9; - C.cbUseMeasuredCurrent.Layout.Column = [1 2]; - - infoUi = labkit.ui.view.section(ui.summaryResultsGrid, ... - 'Current File Summary', 1, [11 2]); - gi = infoUi.grid; - C.txtControlMode = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 1, 'label', 'Control mode:')); - C.txtDetect = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 2, 'label', 'Detection:')); - C.txtDelay = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 3, 'label', 'Delay used:')); - C.txtArea = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 4, 'label', 'Area:')); - C.txtEmc = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 5, 'label', 'Emc:')); - C.txtEma = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 6, 'label', 'Ema:')); - C.txtQc = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 7, 'label', 'Cathodic Q/CIC:')); - C.txtQa = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 8, 'label', 'Anodic Q/CIC:')); - C.txtQt = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 9, 'label', 'Total Q/CIC:')); - C.txtSafe = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 10, 'label', 'Safety:')); - C.txtBest = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 11, 'label', 'Best safe among loaded:')); - - actionUi = labkit.ui.view.section(ui.filesAnalysisGrid, 'Plot / Debug', 3, [2 3]); - ga = actionUi.grid; - btnRefresh = uibutton(ga, 'Text', 'Refresh plots', ... - 'ButtonPushedFcn', callbacks.onRefreshPlots); - btnRefresh.Layout.Row = 1; btnRefresh.Layout.Column = 1; - btnSwap = uibutton(ga, 'Text', 'Swap top / bottom', ... - 'ButtonPushedFcn', callbacks.onSwapPlots); - btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 2; - btnReset = uibutton(ga, 'Text', 'Reset axes', ... - 'ButtonPushedFcn', callbacks.onResetAxes); - btnReset.Layout.Row = 1; btnReset.Layout.Column = 3; - - C.cbShowMarkers = uicheckbox(ga, 'Text', 'Show debug markers', ... - 'Value', true, 'ValueChangedFcn', callbacks.onRefreshPlots); - C.cbShowMarkers.Layout.Row = 2; C.cbShowMarkers.Layout.Column = 1; - C.cbShowLimits = uicheckbox(ga, 'Text', 'Show window limits', ... - 'Value', true, 'ValueChangedFcn', callbacks.onRefreshPlots); - C.cbShowLimits.Layout.Row = 2; C.cbShowLimits.Layout.Column = 2; - C.cbShowShading = uicheckbox(ga, 'Text', 'Shade pulse windows', ... - 'Value', true, 'ValueChangedFcn', callbacks.onRefreshPlots); - C.cbShowShading.Layout.Row = 2; C.cbShowShading.Layout.Column = 3; - - tableUi = labkit.ui.view.panel(ui.summaryResultsGrid, 'table', ... - 'Batch Results', 2, ... - {'File','Amp(A)','Emc(V)','Ema(V)','Qc(mC/cm^2)','Qa(mC/cm^2)','Qtot(mC/cm^2)','Safe'}, ... - cell(0, 8)); - C.tbl = tableUi.table; - - logUi = labkit.ui.view.panel(ui.logGrid, 'log', 1); - C.txtLog = logUi.textArea; - - C.topPlotDefaults = struct('x', 'Time (s)', 'y', 'VT: Vf vs time', 'grid', true); - C.bottomPlotDefaults = struct('x', 'Time (s)', 'y', 'IT: Im vs time', 'grid', true); - C.plotControls = cic.ui.topBottomPlotControls( ... - ui.topControlsPanel, ... - ui.bottomControlsPanel, ... - {'Time (s)', 'Sample #'}, ... - {'VT: Vf vs time', 'IT: Im vs time'}, ... - C.topPlotDefaults, ... - C.bottomPlotDefaults, ... - callbacks.onRefreshPlots); - C.ddTopX = C.plotControls.topX; - C.ddTopY = C.plotControls.topY; - C.cbTopGrid = C.plotControls.topGridCheckbox; - C.axTop = ui.topAxes; - C.ddBotX = C.plotControls.bottomX; - C.ddBotY = C.plotControls.bottomY; - C.cbBotGrid = C.plotControls.bottomGridCheckbox; - C.axBottom = ui.bottomAxes; -end diff --git a/apps/electrochem/cic/+cic/+ui/buildSpec.m b/apps/electrochem/cic/+cic/+ui/buildSpec.m new file mode 100644 index 0000000..48d56cf --- /dev/null +++ b/apps/electrochem/cic/+cic/+ui/buildSpec.m @@ -0,0 +1,179 @@ +% Expected caller: cic.ui.runApp. Input is a callback struct whose fields are +% app-owned callback handles. Output is a data-only UI 2.0 workbench spec for +% the CIC app. +function spec = buildSpec(callbacks) + + presetItems = {'Pt (-0.6 to 0.8 V)', 'PEDOT:PSS (-0.9 to 0.6 V)', 'Custom'}; + pulseModes = {'Metadata first, then auto', 'Metadata only', 'Auto from Im only'}; + cicModes = {'Cathodic phase', 'Anodic phase', 'Total biphasic'}; + cicUnits = {'mC/cm^2', 'uC/cm^2'}; + xChoices = {'Time (s)', 'Sample #'}; + yChoices = {'VT: Vf vs time', 'IT: Im vs time'}; + + spec = labkit.ui.spec.app("cicApp", ... + "Gamry CIC GUI (Voltage Transient)", ... + "position", [40 30 1680 980], ... + "leftWidth", 430, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("filesSection", "Files", { ... + labkit.ui.spec.pathPanel("files", "Files", ... + "mode", "multiFile", ... + "selectionMode", "single", ... + "chooseLabel", "Open DTA file(s)", ... + "clearLabel", "Clear all", ... + "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... + "status", "No files loaded", ... + "emptyText", "No files loaded", ... + "onChoose", callbackValue(callbacks, "openFilesChosen"), ... + "onClear", callbackValue(callbacks, "clearAll"), ... + "onSelectionChange", callbackValue(callbacks, "fileSelectionChanged")), ... + labkit.ui.spec.actionGroup("fileActions", { ... + labkit.ui.spec.action("openFolder", ... + "Open folder recursively", callbackValue(callbacks, "openFolder")), ... + labkit.ui.spec.action("exportResults", ... + "Export results CSV", callbackValue(callbacks, "exportResults"))})}), ... + labkit.ui.spec.section("analysisSettings", "Analysis Settings", { ... + labkit.ui.spec.field("preset", "Window preset:", ... + "kind", "dropdown", ... + "items", presetItems, ... + "value", presetItems{1}, ... + "onChange", callbackValue(callbacks, "presetChanged")), ... + labkit.ui.spec.field("cathLimit", "Cathodic limit (V):", ... + "kind", "spinner", ... + "value", -0.6, ... + "limits", [-10 10], ... + "step", 0.01, ... + "valueDisplayFormat", "%.6g", ... + "onChange", callbackValue(callbacks, "analyzeCurrentFile")), ... + labkit.ui.spec.field("anodLimit", "Anodic limit (V):", ... + "kind", "spinner", ... + "value", 0.8, ... + "limits", [-10 10], ... + "step", 0.01, ... + "valueDisplayFormat", "%.6g", ... + "onChange", callbackValue(callbacks, "analyzeCurrentFile")), ... + labkit.ui.spec.field("delayUs", "Sample delay after pulse end:", ... + "kind", "spinner", ... + "value", 10, ... + "limits", [0 inf], ... + "step", 1, ... + "valueDisplayFormat", "%.6g", ... + "onChange", callbackValue(callbacks, "analyzeCurrentFile")), ... + labkit.ui.spec.field("area", "Area override (cm^2):", ... + "kind", "text", ... + "value", "", ... + "onChange", callbackValue(callbacks, "analyzeCurrentFile")), ... + labkit.ui.spec.field("pulseMode", "Pulse detection:", ... + "kind", "dropdown", ... + "items", pulseModes, ... + "value", pulseModes{1}, ... + "onChange", callbackValue(callbacks, "analyzeCurrentFile")), ... + labkit.ui.spec.field("cicMode", "CIC summary mode:", ... + "kind", "dropdown", ... + "items", cicModes, ... + "value", "Total biphasic", ... + "onChange", callbackValue(callbacks, "refreshResultsSummary")), ... + labkit.ui.spec.field("cicUnit", "CIC unit:", ... + "kind", "dropdown", ... + "items", cicUnits, ... + "value", cicUnits{1}, ... + "onChange", callbackValue(callbacks, "refreshCICUnitDisplays")), ... + labkit.ui.spec.field("useMeasuredCurrent", ... + "Use measured Im integration for charge (recommended)", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "analyzeCurrentFile"))}), ... + labkit.ui.spec.section("plotDebug", "Plot / Debug", { ... + labkit.ui.spec.actionGroup("plotActions", { ... + labkit.ui.spec.action("refreshPlots", ... + "Refresh plots", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.action("swapPlots", ... + "Swap top / bottom", callbackValue(callbacks, "swapPlots")), ... + labkit.ui.spec.action("resetAxes", ... + "Reset axes", callbackValue(callbacks, "resetAxes"))}), ... + labkit.ui.spec.field("showMarkers", "Show debug markers", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("showLimits", "Show window limits", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("showShading", "Shade pulse windows", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots"))}), ... + labkit.ui.spec.section("plotSelections", "Plot Selections", { ... + labkit.ui.spec.field("topX", "Top X:", ... + "kind", "dropdown", ... + "items", xChoices, ... + "value", "Time (s)", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("topY", "Top Y:", ... + "kind", "dropdown", ... + "items", yChoices, ... + "value", "VT: Vf vs time", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("topGrid", "Grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("bottomX", "Bottom X:", ... + "kind", "dropdown", ... + "items", xChoices, ... + "value", "Time (s)", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("bottomY", "Bottom Y:", ... + "kind", "dropdown", ... + "items", yChoices, ... + "value", "IT: Im vs time", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("bottomGrid", "Grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots"))})}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("currentSummary", "Current File Summary", { ... + summaryField("controlMode", "Control mode:"), ... + summaryField("detect", "Detection:"), ... + summaryField("delay", "Delay used:"), ... + summaryField("areaSummary", "Area:"), ... + summaryField("emc", "Emc:"), ... + summaryField("ema", "Ema:"), ... + summaryField("qc", "Cathodic Q/CIC:"), ... + summaryField("qa", "Anodic Q/CIC:"), ... + summaryField("qt", "Total Q/CIC:"), ... + summaryField("safe", "Safety:"), ... + summaryField("best", "Best safe among loaded:")}), ... + labkit.ui.spec.section("batchResults", "Batch Results", { ... + labkit.ui.spec.resultTable("results", "Batch Results", ... + "columns", {'File','Amp(A)','Emc(V)','Ema(V)', ... + 'Qc(mC/cm^2)','Qa(mC/cm^2)','Qtot(mC/cm^2)', ... + 'Safe'}, ... + "data", cell(0, 8))})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log")})})}, ... + "workspace", labkit.ui.spec.workspace("plots", "Plots", { ... + labkit.ui.spec.previewArea("plotAxes", "Plots", ... + "layout", "stack", ... + "count", 2, ... + "axisIds", {'top', 'bottom'}, ... + "axisTitles", {'Top Plot', 'Bottom Plot'})}, ... + "rowSpacing", 10)); +end + +function spec = summaryField(id, labelText) + spec = labkit.ui.spec.field(id, labelText, ... + "kind", "readonly", ... + "value", "-"); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/electrochem/cic/+cic/+ui/createRightAxesPair.m b/apps/electrochem/cic/+cic/+ui/createRightAxesPair.m deleted file mode 100644 index ffafa93..0000000 --- a/apps/electrochem/cic/+cic/+ui/createRightAxesPair.m +++ /dev/null @@ -1,45 +0,0 @@ -% App-owned CIC right-side axes layout helper. Expected caller: cic.ui.buildControls. -% Inputs are the shell UI struct, axes titles, and whether plot-control panels -% are needed. Output is the UI struct with top/bottom axes and panel fields. -% Side effects are limited to creating controls on the shell right grid. -function ui = createRightAxesPair(ui, topTitle, bottomTitle, showControls) -%CREATERIGHTAXESPAIR Create CIC top/bottom plot axes. - - if showControls - ui.topControlsPanel = uipanel(ui.rightGrid, 'Title', topTitle); - ui.topControlsPanel.Layout.Row = 1; - ui.topAxes = createOneAxes(ui.rightGrid, 2, topTitle); - - ui.bottomControlsPanel = uipanel(ui.rightGrid, 'Title', bottomTitle); - ui.bottomControlsPanel.Layout.Row = 3; - ui.bottomAxes = createOneAxes(ui.rightGrid, 4, bottomTitle); - else - ui.topControlsPanel = []; - ui.bottomControlsPanel = []; - ui.topAxes = createOneAxes(ui.rightGrid, 1, topTitle); - ui.bottomAxes = createOneAxes(ui.rightGrid, 2, bottomTitle); - end -end - -function ax = createOneAxes(parent, row, titleText) - ax = uiaxes(parent); - ax.Layout.Row = row; - title(ax, titleText); - labkit.ui.view.draw(ax, 'popout'); - disableAxesInteractivity(ax); -end - -function disableAxesInteractivity(ax) - try - disableDefaultInteractivity(ax); - catch - end - try - ax.Interactions = []; - catch - end - try - ax.Toolbar.Visible = 'off'; - catch - end -end diff --git a/apps/electrochem/cic/+cic/+ui/topBottomPlotControls.m b/apps/electrochem/cic/+cic/+ui/topBottomPlotControls.m deleted file mode 100644 index f80b753..0000000 --- a/apps/electrochem/cic/+cic/+ui/topBottomPlotControls.m +++ /dev/null @@ -1,64 +0,0 @@ -% App-owned CIC top/bottom plot controls helper. Expected caller: -% cic.ui.buildControls. Inputs are parent panels, axis items, default -% selections, and a value-change callback. Output is a controls struct with -% handles plus setSelections/swapSelections closures. Side effects are limited -% to creating controls on the supplied panels. -function ui = topBottomPlotControls(topPanel, bottomPanel, xItems, yItems, topDefaults, bottomDefaults, valueChangedFcn) -%TOPBOTTOMPLOTCONTROLS Create CIC top/bottom plot controls. - - if nargin < 7 - valueChangedFcn = []; - end - - ui = struct(); - [ui.topGrid, ui.topX, ui.topY, ui.topGridCheckbox] = createOneRow( ... - topPanel, xItems, yItems, topDefaults, valueChangedFcn); - [ui.bottomGrid, ui.bottomX, ui.bottomY, ui.bottomGridCheckbox] = createOneRow( ... - bottomPanel, xItems, yItems, bottomDefaults, valueChangedFcn); - ui.setSelections = @setSelections; - ui.swapSelections = @swapSelections; - - function setSelections(topSelection, bottomSelection) - applySelection(ui.topX, ui.topY, topSelection); - applySelection(ui.bottomX, ui.bottomY, bottomSelection); - end - - function swapSelections() - topSelection = struct('x', ui.topX.Value, 'y', ui.topY.Value); - bottomSelection = struct('x', ui.bottomX.Value, 'y', ui.bottomY.Value); - setSelections(bottomSelection, topSelection); - end -end - -function [grid, ddX, ddY, cbGrid] = createOneRow(parent, xItems, yItems, defaults, valueChangedFcn) - grid = uigridlayout(parent, [1 5]); - grid.ColumnWidth = {'fit', '1x', 'fit', '1x', '1x'}; - grid.Padding = [8 6 8 6]; - grid.ColumnSpacing = 8; - - uilabel(grid, 'Text', 'X:', 'HorizontalAlignment', 'right'); - ddX = uidropdown(grid, ... - 'Items', xItems, ... - 'Value', defaults.x, ... - 'ValueChangedFcn', valueChangedFcn); - - uilabel(grid, 'Text', 'Y:', 'HorizontalAlignment', 'right'); - ddY = uidropdown(grid, ... - 'Items', yItems, ... - 'Value', defaults.y, ... - 'ValueChangedFcn', valueChangedFcn); - - cbGrid = uicheckbox(grid, ... - 'Text', 'Grid', ... - 'Value', defaults.grid, ... - 'ValueChangedFcn', valueChangedFcn); -end - -function applySelection(ddX, ddY, selection) - if isfield(selection, 'x') && any(strcmp(ddX.Items, selection.x)) - ddX.Value = selection.x; - end - if isfield(selection, 'y') && any(strcmp(ddY.Items, selection.y)) - ddY.Value = selection.y; - end -end diff --git a/apps/electrochem/cic/+cic/+ui/runApp.m b/apps/electrochem/cic/+cic/run.m similarity index 74% rename from apps/electrochem/cic/+cic/+ui/runApp.m rename to apps/electrochem/cic/+cic/run.m index 596533a..2e3d844 100644 --- a/apps/electrochem/cic/+cic/+ui/runApp.m +++ b/apps/electrochem/cic/+cic/run.m @@ -2,7 +2,7 @@ % Input is the debug context prepared by the public launcher. Output is the app % figure. Side effects are GUI creation, user-driven file I/O, exports, % plotting, and debug trace attachment exactly as in the original entrypoint body. -function fig = runApp(debugLog) +function fig = run(debugLog) %RUNCICAPP Build and run the app body. S = struct(); @@ -11,63 +11,60 @@ S.current = []; callbacks = struct( ... - 'onOpenFiles', @onOpenFiles, ... - 'onOpenFolder', @onOpenFolder, ... - 'onClearAll', @(~,~) clearAllFiles(), ... - 'onExport', @(~,~) exportResultsCSV(), ... - 'onSelectFile', @(~,~) onSelectFile(), ... - 'onPresetChanged', @(~,~) onPresetChanged(), ... - 'onAnalyzeCurrentFile', @(~,~) analyzeCurrentFile(), ... - 'onRefreshResultsSummary', @(~,~) refreshResultsSummary(), ... - 'onRefreshCICUnitDisplays', @(~,~) refreshCICUnitDisplays(), ... - 'onRefreshPlots', @(~,~) refreshPlots(), ... - 'onSwapPlots', @(~,~) swapPlots(), ... - 'onResetAxes', @(~,~) resetAxes()); - C = cic.ui.buildControls(callbacks); - - fig = C.fig; - lbFiles = C.lbFiles; - txtLoaded = C.txtLoaded; - ddPreset = C.ddPreset; - edCathLim = C.edCathLim; - edAnodLim = C.edAnodLim; - edDelayUs = C.edDelayUs; - edArea = C.edArea; - ddPulseMode = C.ddPulseMode; - ddCICMode = C.ddCICMode; - ddCICUnit = C.ddCICUnit; - cbUseMeasuredCurrent = C.cbUseMeasuredCurrent; - S.txtControlMode = C.txtControlMode; - S.txtDetect = C.txtDetect; - S.txtDelay = C.txtDelay; - S.txtArea = C.txtArea; - S.txtEmc = C.txtEmc; - S.txtEma = C.txtEma; - S.txtQc = C.txtQc; - S.txtQa = C.txtQa; - S.txtQt = C.txtQt; - S.txtSafe = C.txtSafe; - S.txtBest = C.txtBest; - cbShowMarkers = C.cbShowMarkers; - cbShowLimits = C.cbShowLimits; - cbShowShading = C.cbShowShading; - tbl = C.tbl; - txtLog = C.txtLog; - topPlotDefaults = C.topPlotDefaults; - bottomPlotDefaults = C.bottomPlotDefaults; - plotControls = C.plotControls; - ddTopX = C.ddTopX; - ddTopY = C.ddTopY; - cbTopGrid = C.cbTopGrid; - axTop = C.axTop; - ddBotX = C.ddBotX; - ddBotY = C.ddBotY; - cbBotGrid = C.cbBotGrid; - axBottom = C.axBottom; + "openFilesChosen", @onOpenFilesChosen, ... + "openFolder", @onOpenFolder, ... + "clearAll", @(~,~) clearAllFiles(), ... + "exportResults", @(~,~) exportResultsCSV(), ... + "fileSelectionChanged", @(~,~) onSelectFile(), ... + "presetChanged", @(~,~) onPresetChanged(), ... + "analyzeCurrentFile", @(~,~) analyzeCurrentFile(), ... + "refreshResultsSummary", @(~,~) refreshResultsSummary(), ... + "refreshCICUnitDisplays", @(~,~) refreshCICUnitDisplays(), ... + "refreshPlots", @(~,~) refreshPlots(), ... + "swapPlots", @(~,~) swapPlots(), ... + "resetAxes", @(~,~) resetAxes()); + spec = cic.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + + fig = ui.figure; + lbFiles = ui.controls.files.listbox; + txtLoaded = ui.controls.files.status; + ddPreset = ui.controls.preset.valueHandle; + edCathLim = ui.controls.cathLimit.valueHandle; + edAnodLim = ui.controls.anodLimit.valueHandle; + edDelayUs = ui.controls.delayUs.valueHandle; + edArea = ui.controls.area.valueHandle; + ddPulseMode = ui.controls.pulseMode.valueHandle; + ddCICMode = ui.controls.cicMode.valueHandle; + ddCICUnit = ui.controls.cicUnit.valueHandle; + cbUseMeasuredCurrent = ui.controls.useMeasuredCurrent.valueHandle; + S.txtControlMode = ui.controls.controlMode.valueHandle; + S.txtDetect = ui.controls.detect.valueHandle; + S.txtDelay = ui.controls.delay.valueHandle; + S.txtArea = ui.controls.areaSummary.valueHandle; + S.txtEmc = ui.controls.emc.valueHandle; + S.txtEma = ui.controls.ema.valueHandle; + S.txtQc = ui.controls.qc.valueHandle; + S.txtQa = ui.controls.qa.valueHandle; + S.txtQt = ui.controls.qt.valueHandle; + S.txtSafe = ui.controls.safe.valueHandle; + S.txtBest = ui.controls.best.valueHandle; + cbShowMarkers = ui.controls.showMarkers.valueHandle; + cbShowLimits = ui.controls.showLimits.valueHandle; + cbShowShading = ui.controls.showShading.valueHandle; + tbl = ui.controls.results.table; + topPlotDefaults = struct('x', 'Time (s)', 'y', 'VT: Vf vs time', 'grid', true); + bottomPlotDefaults = struct('x', 'Time (s)', 'y', 'IT: Im vs time', 'grid', true); + ddTopX = ui.controls.topX.valueHandle; + ddTopY = ui.controls.topY.valueHandle; + cbTopGrid = ui.controls.topGrid.valueHandle; + axTop = ui.controls.plotAxes.axesById.top; + ddBotX = ui.controls.bottomX.valueHandle; + ddBotY = ui.controls.bottomY.valueHandle; + cbBotGrid = ui.controls.bottomGrid.valueHandle; + axBottom = ui.controls.plotAxes.axesById.bottom; if debugLog.enabled - debugLog.attachTextLog(txtLog); debugLog.trace('CIC debug trace enabled.'); - debugLog.instrumentFigure(fig); end %% App callbacks, session actions, refresh, plotting, and export function onPresetChanged() @@ -84,20 +81,12 @@ function onPresetChanged() analyzeCurrentFile(); end - function onOpenFiles(~,~) - [f,p] = uigetfile({'*.DTA;*.dta','Gamry DTA (*.DTA)';'*.*','All files'}, ... - 'Select one or more Gamry DTA files','MultiSelect','on'); - if isequal(f,0) + function onOpenFilesChosen(~, event) + if isempty(event.paths) addLog('Open cancelled.'); return; end - - if ischar(f) || isstring(f) - f = {char(f)}; - end - - filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); - loadDTAFiles(filepaths); + loadDTAFiles(event.paths); end function onOpenFolder(~,~) @@ -232,15 +221,18 @@ function clearAllFiles() function refreshFileList() if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listSelection', {}); - txtLoaded.Value = C.loadedText; + labkit.ui.view.setListItems(ui, 'files', {}); + txtLoaded.Value = 'No files loaded'; S.current = []; return; end names = {S.items.name}; - [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', names, S.current); - S.current = idx(1); + labkit.ui.view.setListItems(ui, 'files', names); + if isempty(S.current) || S.current < 1 || S.current > numel(names) + S.current = 1; + end + lbFiles.Value = names{S.current}; txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); end @@ -277,8 +269,8 @@ function refreshCICUnitDisplays() end function refreshPlots() - labkit.ui.view.draw(axTop, 'clear'); - labkit.ui.view.draw(axBottom, 'clear'); + clearAxis(axTop); + clearAxis(axBottom); if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) title(axTop,'Top Plot'); title(axBottom,'Bottom Plot'); @@ -366,7 +358,15 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) end function swapPlots() - plotControls.swapSelections(); + topX = ddTopX.Value; + topY = ddTopY.Value; + topGrid = cbTopGrid.Value; + ddTopX.Value = ddBotX.Value; + ddTopY.Value = ddBotY.Value; + cbTopGrid.Value = cbBotGrid.Value; + ddBotX.Value = topX; + ddBotY.Value = topY; + cbBotGrid.Value = topGrid; refreshPlots(); end @@ -376,12 +376,17 @@ function resetAxes() end function restoreDefaultPlotSelections() - plotControls.setSelections(topPlotDefaults, bottomPlotDefaults); + ddTopX.Value = topPlotDefaults.x; + ddTopY.Value = topPlotDefaults.y; + cbTopGrid.Value = topPlotDefaults.grid; + ddBotX.Value = bottomPlotDefaults.x; + ddBotY.Value = bottomPlotDefaults.y; + cbBotGrid.Value = bottomPlotDefaults.grid; end function resetAxesToDefaultState() - labkit.ui.view.draw(axTop, 'reset', 'Top Plot', true); - labkit.ui.view.draw(axBottom, 'reset', 'Bottom Plot', true); + resetAxis(axTop, 'Top Plot'); + resetAxis(axBottom, 'Bottom Plot'); end function exportResultsCSV() @@ -405,8 +410,19 @@ function exportResultsCSV() %% ===================== Logging ===================== function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); + labkit.ui.view.appendLog(ui, 'appLog', msg); debugLog.append(msg); end end + +function clearAxis(ax) + cla(ax); +end + +function resetAxis(ax, titleText) + cla(ax); + title(ax, titleText); + xlabel(ax, ''); + ylabel(ax, ''); +end diff --git a/apps/electrochem/cic/labkit_CIC_app.m b/apps/electrochem/cic/labkit_CIC_app.m index b436691..83c9788 100644 --- a/apps/electrochem/cic/labkit_CIC_app.m +++ b/apps/electrochem/cic/labkit_CIC_app.m @@ -38,7 +38,7 @@ error('labkit_CIC_app:TooManyOutputs', 'labkit_CIC_app returns at most the app figure handle.'); end - fig = cic.ui.runApp(debugLog); + fig = cic.run(debugLog); if nargout >= 1 varargout{1} = fig; end diff --git a/apps/electrochem/csc/+csc/+ui/buildControls.m b/apps/electrochem/csc/+csc/+ui/buildControls.m deleted file mode 100644 index 08f523c..0000000 --- a/apps/electrochem/csc/+csc/+ui/buildControls.m +++ /dev/null @@ -1,154 +0,0 @@ -% Build the CSC app's static shell and controls. Expected caller is -% csc.ui.runApp. Input callbacks is a struct of runner-owned callback handles. -% Output is a struct of UI handles used by the runner. Side effects are limited -% to creating MATLAB UI components in a new figure. -function C = buildControls(callbacks) -%BUILDCONTROLS Create CSC app controls. - - C = struct(); - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry DTA GUI (literature CSC)', ... - 'position', [50 30 1580 950], ... - 'leftWidth', 390, ... - 'options', struct( ... - 'rightTitle', 'Plots', ... - 'rightGridSize', [4 1], ... - 'rightRowHeight', {{'fit', '1x', 'fit', '1x'}}, ... - 'rightRowSpacing', 10))); - ui = csc.ui.createRightAxesPair(ui, 'Top Plot', 'Bottom Plot', true); - C.fig = ui.fig; - - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'clearAll', 'Clear all', ... - 'export', 'Reload selected', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(ui.filesAnalysisGrid, 'files', fileLabels, callbacks); - C.lbFiles = fileUi.listbox; - C.txtLoaded = fileUi.loadedText; - - curveUi = labkit.ui.view.section(ui.filesAnalysisGrid, 'Curve', 2, [4 2]); - gf = curveUi.grid; - uilabel(gf, 'Text', 'File:', 'HorizontalAlignment', 'right'); - C.txtFile = labkit.ui.view.form(gf, struct('kind', 'readonly')); - C.txtFile.Layout.Row = 1; C.txtFile.Layout.Column = 2; - - uilabel(gf, 'Text', 'Scan rate:', 'HorizontalAlignment', 'right'); - C.txtScan = labkit.ui.view.form(gf, struct('kind', 'readonly')); - C.txtScan.Layout.Row = 2; C.txtScan.Layout.Column = 2; - - uilabel(gf, 'Text', 'Curve:', 'HorizontalAlignment', 'right'); - C.ddCurve = uidropdown(gf, 'Items', {'(none)'}, ... - 'ValueChangedFcn', callbacks.onCurveChanged); - C.ddCurve.Layout.Row = 3; C.ddCurve.Layout.Column = 2; - - btnAuto = uibutton(gf, 'Text', 'Auto CV + CT', ... - 'ButtonPushedFcn', callbacks.onAutoPresetAndRefresh); - btnAuto.Layout.Row = 4; btnAuto.Layout.Column = [1 2]; - - actionOpts = struct('columnWidth', {{'1x', '1x'}}); - actionUi = labkit.ui.view.section(ui.filesAnalysisGrid, ... - 'Actions', 3, [2 2], actionOpts); - ga = actionUi.grid; - btnSwap = uibutton(ga, 'Text', 'Swap Top/Bottom', ... - 'ButtonPushedFcn', callbacks.onSwapPlots); - btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 1; - btnCompare = uibutton(ga, 'Text', 'Compare Q / CSC', ... - 'ButtonPushedFcn', callbacks.onRefreshCompare); - btnCompare.Layout.Row = 1; btnCompare.Layout.Column = 2; - btnRefresh = uibutton(ga, 'Text', 'Refresh Plots', ... - 'ButtonPushedFcn', callbacks.onRefreshPlotsOnly); - btnRefresh.Layout.Row = 2; btnRefresh.Layout.Column = 1; - btnClear = uibutton(ga, 'Text', 'Clear Both', ... - 'ButtonPushedFcn', callbacks.onClearBothAxes); - btnClear.Layout.Row = 2; btnClear.Layout.Column = 2; - - compUi = labkit.ui.view.section(ui.summaryResultsGrid, ... - 'CSC / Comparison', 1, [8 2]); - gc = compUi.grid; - uilabel(gc, 'Text', 'Mode:', 'HorizontalAlignment', 'right'); - C.ddMode = uidropdown(gc, ... - 'Items', {'Full', 'Cathodic', 'Anodic'}, ... - 'Value', 'Full', ... - 'ValueChangedFcn', callbacks.onRefreshCompare); - C.ddMode.Layout.Row = 1; C.ddMode.Layout.Column = 2; - - uilabel(gc, 'Text', 'Area (cm^2):', 'HorizontalAlignment', 'right'); - C.edArea = uieditfield(gc, 'text', 'Value', ''); - C.edArea.ValueChangedFcn = callbacks.onRefreshCompare; - C.edArea.Layout.Row = 2; C.edArea.Layout.Column = 2; - - uilabel(gc, 'Text', 'CT charge / CSC:', 'HorizontalAlignment', 'right'); - C.txtQct = labkit.ui.view.form(gc, struct('kind', 'readonly')); - C.txtQct.Layout.Row = 3; C.txtQct.Layout.Column = 2; - - uilabel(gc, 'Text', 'CV charge / CSC:', 'HorizontalAlignment', 'right'); - C.txtQcv = labkit.ui.view.form(gc, struct('kind', 'readonly')); - C.txtQcv.Layout.Row = 4; C.txtQcv.Layout.Column = 2; - - uilabel(gc, 'Text', 'Difference:', 'HorizontalAlignment', 'right'); - C.txtDiff = labkit.ui.view.form(gc, struct('kind', 'readonly')); - C.txtDiff.Layout.Row = 5; C.txtDiff.Layout.Column = 2; - - uilabel(gc, 'Text', 'Relative diff:', 'HorizontalAlignment', 'right'); - C.txtRel = labkit.ui.view.form(gc, struct('kind', 'readonly')); - C.txtRel.Layout.Row = 6; C.txtRel.Layout.Column = 2; - - uilabel(gc, 'Text', 'max|dt-|dV|/v|:', 'HorizontalAlignment', 'right'); - C.txtDtErr = labkit.ui.view.form(gc, struct('kind', 'readonly')); - C.txtDtErr.Layout.Row = 7; C.txtDtErr.Layout.Column = 2; - - C.lblStatus = uilabel(gc, 'Text', 'Ready'); - C.lblStatus.Layout.Row = 8; C.lblStatus.Layout.Column = [1 2]; - C.lblStatus.FontWeight = 'bold'; - - logUi = labkit.ui.view.panel(ui.logGrid, 'log', 1, {'GUI started.'}); - C.txtLog = logUi.textArea; - C.txtLog.Value = {'GUI started.'}; - - topPlotDefaults = struct('x', '(none)', 'y', '(none)', 'grid', true); - bottomPlotDefaults = struct('x', '(none)', 'y', '(none)', 'grid', true); - C.plotControls = csc.ui.topBottomPlotControls( ... - ui.topControlsPanel, ... - ui.bottomControlsPanel, ... - {'(none)'}, ... - {'(none)'}, ... - topPlotDefaults, ... - bottomPlotDefaults, ... - callbacks.onRefreshPlotsOnly); - C.ddTopX = C.plotControls.topX; - C.ddTopY = C.plotControls.topY; - C.cbTopGrid = C.plotControls.topGridCheckbox; - C.ddBotX = C.plotControls.bottomX; - C.ddBotY = C.plotControls.bottomY; - C.cbBotGrid = C.plotControls.bottomGridCheckbox; - C.axTop = ui.topAxes; - C.axBottom = ui.bottomAxes; - title(C.axTop, 'Top Plot'); - xlabel(C.axTop, 'X'); - ylabel(C.axTop, 'Y'); - title(C.axBottom, 'Bottom Plot'); - xlabel(C.axBottom, 'X'); - ylabel(C.axBottom, 'Y'); - - C.plotControls.topGrid.ColumnWidth = {'fit','1x','fit','1x','fit','fit','fit'}; - C.cbTopHold = uicheckbox(C.plotControls.topGrid, ... - 'Text', 'Hold', 'Value', false); - C.cbTopHold.Layout.Row = 1; C.cbTopHold.Layout.Column = 6; - C.cbTopTrim = uicheckbox(C.plotControls.topGrid, ... - 'Text', 'Show Trim', 'Value', true, ... - 'ValueChangedFcn', callbacks.onRefreshCompare); - C.cbTopTrim.Layout.Row = 1; C.cbTopTrim.Layout.Column = 7; - - C.plotControls.bottomGrid.ColumnWidth = {'fit','1x','fit','1x','fit','fit','fit'}; - C.cbBotHold = uicheckbox(C.plotControls.bottomGrid, ... - 'Text', 'Hold', 'Value', false); - C.cbBotHold.Layout.Row = 1; C.cbBotHold.Layout.Column = 6; - C.cbBotTrim = uicheckbox(C.plotControls.bottomGrid, ... - 'Text', 'Show Trim', 'Value', true, ... - 'ValueChangedFcn', callbacks.onRefreshCompare); - C.cbBotTrim.Layout.Row = 1; C.cbBotTrim.Layout.Column = 7; -end diff --git a/apps/electrochem/csc/+csc/+ui/buildSpec.m b/apps/electrochem/csc/+csc/+ui/buildSpec.m new file mode 100644 index 0000000..1ed7949 --- /dev/null +++ b/apps/electrochem/csc/+csc/+ui/buildSpec.m @@ -0,0 +1,143 @@ +% Expected caller: csc.ui.runApp. Input is a callback struct whose fields are +% app-owned callback handles. Output is a data-only UI 2.0 workbench spec for +% the CSC app. +function spec = buildSpec(callbacks) + + emptyChoice = {'(none)'}; + modeItems = {'Full', 'Cathodic', 'Anodic'}; + + spec = labkit.ui.spec.app("cscApp", ... + "Gamry DTA GUI (literature CSC)", ... + "position", [50 30 1580 950], ... + "leftWidth", 390, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("filesSection", "Files", { ... + labkit.ui.spec.pathPanel("files", "Files", ... + "mode", "multiFile", ... + "selectionMode", "single", ... + "chooseLabel", "Open DTA file(s)", ... + "clearLabel", "Clear all", ... + "filters", {'*.DTA;*.dta', 'Gamry DTA files (*.DTA)'}, ... + "status", "No files loaded", ... + "emptyText", "No files loaded", ... + "onChoose", callbackValue(callbacks, "openFilesChosen"), ... + "onClear", callbackValue(callbacks, "clearAll"), ... + "onSelectionChange", callbackValue(callbacks, "fileSelectionChanged")), ... + labkit.ui.spec.actionGroup("fileActions", { ... + labkit.ui.spec.action("openFolder", ... + "Open folder recursively", callbackValue(callbacks, "openFolder")), ... + labkit.ui.spec.action("reloadSelected", ... + "Reload selected", callbackValue(callbacks, "reloadSelected"))})}), ... + labkit.ui.spec.section("curveSection", "Curve", { ... + labkit.ui.spec.field("filePath", "File:", ... + "kind", "readonly", ... + "value", ""), ... + labkit.ui.spec.field("scanRate", "Scan rate:", ... + "kind", "readonly", ... + "value", ""), ... + labkit.ui.spec.field("curve", "Curve:", ... + "kind", "dropdown", ... + "items", emptyChoice, ... + "value", emptyChoice{1}, ... + "onChange", callbackValue(callbacks, "curveChanged")), ... + labkit.ui.spec.action("autoCvCt", "Auto CV + CT", ... + callbackValue(callbacks, "autoPresetAndRefresh"))}), ... + labkit.ui.spec.section("actions", "Actions", { ... + labkit.ui.spec.actionGroup("plotActions", { ... + labkit.ui.spec.action("swapPlots", ... + "Swap Top/Bottom", callbackValue(callbacks, "swapPlots")), ... + labkit.ui.spec.action("compareQ", ... + "Compare Q / CSC", callbackValue(callbacks, "refreshCompare")), ... + labkit.ui.spec.action("refreshPlots", ... + "Refresh Plots", callbackValue(callbacks, "refreshPlotsOnly")), ... + labkit.ui.spec.action("clearBoth", ... + "Clear Both", callbackValue(callbacks, "clearBothAxes"))})}), ... + labkit.ui.spec.section("plotSelections", "Plot Selections", { ... + labkit.ui.spec.field("topX", "Top X:", ... + "kind", "dropdown", ... + "items", emptyChoice, ... + "value", emptyChoice{1}, ... + "onChange", callbackValue(callbacks, "refreshPlotsOnly")), ... + labkit.ui.spec.field("topY", "Top Y:", ... + "kind", "dropdown", ... + "items", emptyChoice, ... + "value", emptyChoice{1}, ... + "onChange", callbackValue(callbacks, "refreshPlotsOnly")), ... + labkit.ui.spec.field("topGrid", "Grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlotsOnly")), ... + labkit.ui.spec.field("topHold", "Hold", ... + "kind", "checkbox", ... + "value", false), ... + labkit.ui.spec.field("topTrim", "Show Trim", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshCompare")), ... + labkit.ui.spec.field("bottomX", "Bottom X:", ... + "kind", "dropdown", ... + "items", emptyChoice, ... + "value", emptyChoice{1}, ... + "onChange", callbackValue(callbacks, "refreshPlotsOnly")), ... + labkit.ui.spec.field("bottomY", "Bottom Y:", ... + "kind", "dropdown", ... + "items", emptyChoice, ... + "value", emptyChoice{1}, ... + "onChange", callbackValue(callbacks, "refreshPlotsOnly")), ... + labkit.ui.spec.field("bottomGrid", "Grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlotsOnly")), ... + labkit.ui.spec.field("bottomHold", "Hold", ... + "kind", "checkbox", ... + "value", false), ... + labkit.ui.spec.field("bottomTrim", "Show Trim", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshCompare"))})}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("comparisonSection", "CSC / Comparison", { ... + labkit.ui.spec.field("mode", "Mode:", ... + "kind", "dropdown", ... + "items", modeItems, ... + "value", modeItems{1}, ... + "onChange", callbackValue(callbacks, "refreshCompare")), ... + labkit.ui.spec.field("area", "Area (cm^2):", ... + "kind", "text", ... + "value", "", ... + "onChange", callbackValue(callbacks, "refreshCompare")), ... + summaryField("qct", "CT charge / CSC:"), ... + summaryField("qcv", "CV charge / CSC:"), ... + summaryField("diff", "Difference:"), ... + summaryField("relativeDiff", "Relative diff:"), ... + summaryField("dtError", "max|dt-|dV|/v|:"), ... + summaryField("status", "Status:")})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log", ... + "value", {'GUI started.'})})})}, ... + "workspace", labkit.ui.spec.workspace("plots", "Plots", { ... + labkit.ui.spec.previewArea("plotAxes", "Plots", ... + "layout", "stack", ... + "count", 2, ... + "axisIds", {'top', 'bottom'}, ... + "axisTitles", {'Top Plot', 'Bottom Plot'}, ... + "xLabels", {'X', 'X'}, ... + "yLabels", {'Y', 'Y'})}, ... + "rowSpacing", 10)); +end + +function spec = summaryField(id, labelText) + spec = labkit.ui.spec.field(id, labelText, ... + "kind", "readonly", ... + "value", ""); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/electrochem/csc/+csc/+ui/createRightAxesPair.m b/apps/electrochem/csc/+csc/+ui/createRightAxesPair.m deleted file mode 100644 index 08d5757..0000000 --- a/apps/electrochem/csc/+csc/+ui/createRightAxesPair.m +++ /dev/null @@ -1,45 +0,0 @@ -% App-owned CSC right-side axes layout helper. Expected caller: csc.ui.buildControls. -% Inputs are the shell UI struct, axes titles, and whether plot-control panels -% are needed. Output is the UI struct with top/bottom axes and panel fields. -% Side effects are limited to creating controls on the shell right grid. -function ui = createRightAxesPair(ui, topTitle, bottomTitle, showControls) -%CREATERIGHTAXESPAIR Create CSC top/bottom plot axes. - - if showControls - ui.topControlsPanel = uipanel(ui.rightGrid, 'Title', topTitle); - ui.topControlsPanel.Layout.Row = 1; - ui.topAxes = createOneAxes(ui.rightGrid, 2, topTitle); - - ui.bottomControlsPanel = uipanel(ui.rightGrid, 'Title', bottomTitle); - ui.bottomControlsPanel.Layout.Row = 3; - ui.bottomAxes = createOneAxes(ui.rightGrid, 4, bottomTitle); - else - ui.topControlsPanel = []; - ui.bottomControlsPanel = []; - ui.topAxes = createOneAxes(ui.rightGrid, 1, topTitle); - ui.bottomAxes = createOneAxes(ui.rightGrid, 2, bottomTitle); - end -end - -function ax = createOneAxes(parent, row, titleText) - ax = uiaxes(parent); - ax.Layout.Row = row; - title(ax, titleText); - labkit.ui.view.draw(ax, 'popout'); - disableAxesInteractivity(ax); -end - -function disableAxesInteractivity(ax) - try - disableDefaultInteractivity(ax); - catch - end - try - ax.Interactions = []; - catch - end - try - ax.Toolbar.Visible = 'off'; - catch - end -end diff --git a/apps/electrochem/csc/+csc/+ui/topBottomPlotControls.m b/apps/electrochem/csc/+csc/+ui/topBottomPlotControls.m deleted file mode 100644 index 9e3de88..0000000 --- a/apps/electrochem/csc/+csc/+ui/topBottomPlotControls.m +++ /dev/null @@ -1,64 +0,0 @@ -% App-owned CSC top/bottom plot controls helper. Expected caller: -% csc.ui.buildControls. Inputs are parent panels, axis items, default -% selections, and a value-change callback. Output is a controls struct with -% dropdowns, grid checkboxes, and selection closures. Side effects are limited -% to creating controls on the supplied panels. -function ui = topBottomPlotControls(topPanel, bottomPanel, xItems, yItems, topDefaults, bottomDefaults, valueChangedFcn) -%TOPBOTTOMPLOTCONTROLS Create CSC top/bottom plot controls. - - if nargin < 7 - valueChangedFcn = []; - end - - ui = struct(); - [ui.topGrid, ui.topX, ui.topY, ui.topGridCheckbox] = createOneRow( ... - topPanel, xItems, yItems, topDefaults, valueChangedFcn); - [ui.bottomGrid, ui.bottomX, ui.bottomY, ui.bottomGridCheckbox] = createOneRow( ... - bottomPanel, xItems, yItems, bottomDefaults, valueChangedFcn); - ui.setSelections = @setSelections; - ui.swapSelections = @swapSelections; - - function setSelections(topSelection, bottomSelection) - applySelection(ui.topX, ui.topY, topSelection); - applySelection(ui.bottomX, ui.bottomY, bottomSelection); - end - - function swapSelections() - topSelection = struct('x', ui.topX.Value, 'y', ui.topY.Value); - bottomSelection = struct('x', ui.bottomX.Value, 'y', ui.bottomY.Value); - setSelections(bottomSelection, topSelection); - end -end - -function [grid, ddX, ddY, cbGrid] = createOneRow(parent, xItems, yItems, defaults, valueChangedFcn) - grid = uigridlayout(parent, [1 5]); - grid.ColumnWidth = {'fit', '1x', 'fit', '1x', '1x'}; - grid.Padding = [8 6 8 6]; - grid.ColumnSpacing = 8; - - uilabel(grid, 'Text', 'X:', 'HorizontalAlignment', 'right'); - ddX = uidropdown(grid, ... - 'Items', xItems, ... - 'Value', defaults.x, ... - 'ValueChangedFcn', valueChangedFcn); - - uilabel(grid, 'Text', 'Y:', 'HorizontalAlignment', 'right'); - ddY = uidropdown(grid, ... - 'Items', yItems, ... - 'Value', defaults.y, ... - 'ValueChangedFcn', valueChangedFcn); - - cbGrid = uicheckbox(grid, ... - 'Text', 'Grid', ... - 'Value', defaults.grid, ... - 'ValueChangedFcn', valueChangedFcn); -end - -function applySelection(ddX, ddY, selection) - if isfield(selection, 'x') && any(strcmp(ddX.Items, selection.x)) - ddX.Value = selection.x; - end - if isfield(selection, 'y') && any(strcmp(ddY.Items, selection.y)) - ddY.Value = selection.y; - end -end diff --git a/apps/electrochem/csc/+csc/+ui/runApp.m b/apps/electrochem/csc/+csc/run.m similarity index 76% rename from apps/electrochem/csc/+csc/+ui/runApp.m rename to apps/electrochem/csc/+csc/run.m index 050bf21..555e0ce 100644 --- a/apps/electrochem/csc/+csc/+ui/runApp.m +++ b/apps/electrochem/csc/+csc/run.m @@ -2,7 +2,7 @@ % Input is the debug context prepared by the public launcher. Output is the app % figure. Side effects are GUI creation, user-driven file I/O, exports, % plotting, and debug trace attachment exactly as in the original entrypoint body. -function fig = runApp(debugLog) +function fig = run(debugLog) %RUNCSCAPP Build and run the app body. S = struct(); @@ -15,65 +15,56 @@ S.currentCurve = 1; callbacks = struct( ... - 'onOpenFiles', @onOpenFiles, ... - 'onOpenFolder', @onOpenFolder, ... - 'onClearAll', @(~,~) clearAllFiles(), ... - 'onExport', @(~,~) reloadSelectedFile(), ... - 'onSelectFile', @(~,~) onSelectFile(), ... - 'onCurveChanged', @(~,~) onCurveChanged(), ... - 'onAutoPresetAndRefresh', @(~,~) autoPresetAndRefresh(), ... - 'onSwapPlots', @(~,~) onSwapPlots(), ... - 'onRefreshCompare', @(~,~) refreshCompare(), ... - 'onRefreshPlotsOnly', @(~,~) refreshPlotsOnly(), ... - 'onClearBothAxes', @(~,~) clearBothAxes()); - C = csc.ui.buildControls(callbacks); - - fig = C.fig; - lbFiles = C.lbFiles; - txtLoaded = C.txtLoaded; - txtFile = C.txtFile; - txtScan = C.txtScan; - ddCurve = C.ddCurve; - ddMode = C.ddMode; - edArea = C.edArea; - txtQct = C.txtQct; - txtQcv = C.txtQcv; - txtDiff = C.txtDiff; - txtRel = C.txtRel; - txtDtErr = C.txtDtErr; - lblStatus = C.lblStatus; - txtLog = C.txtLog; - plotControls = C.plotControls; - ddTopX = C.ddTopX; - ddTopY = C.ddTopY; - cbTopGrid = C.cbTopGrid; - ddBotX = C.ddBotX; - ddBotY = C.ddBotY; - cbBotGrid = C.cbBotGrid; - axTop = C.axTop; - axBottom = C.axBottom; - cbTopHold = C.cbTopHold; - cbTopTrim = C.cbTopTrim; - cbBotHold = C.cbBotHold; - cbBotTrim = C.cbBotTrim; + "openFilesChosen", @onOpenFilesChosen, ... + "openFolder", @onOpenFolder, ... + "clearAll", @(~,~) clearAllFiles(), ... + "reloadSelected", @(~,~) reloadSelectedFile(), ... + "fileSelectionChanged", @(~,~) onSelectFile(), ... + "curveChanged", @(~,~) onCurveChanged(), ... + "autoPresetAndRefresh", @(~,~) autoPresetAndRefresh(), ... + "swapPlots", @(~,~) onSwapPlots(), ... + "refreshCompare", @(~,~) refreshCompare(), ... + "refreshPlotsOnly", @(~,~) refreshPlotsOnly(), ... + "clearBothAxes", @(~,~) clearBothAxes()); + spec = csc.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + + fig = ui.figure; + lbFiles = ui.controls.files.listbox; + txtLoaded = ui.controls.files.status; + txtFile = ui.controls.filePath.valueHandle; + txtScan = ui.controls.scanRate.valueHandle; + ddCurve = ui.controls.curve.valueHandle; + ddMode = ui.controls.mode.valueHandle; + edArea = ui.controls.area.valueHandle; + txtQct = ui.controls.qct.valueHandle; + txtQcv = ui.controls.qcv.valueHandle; + txtDiff = ui.controls.diff.valueHandle; + txtRel = ui.controls.relativeDiff.valueHandle; + txtDtErr = ui.controls.dtError.valueHandle; + txtStatus = ui.controls.status.valueHandle; + ddTopX = ui.controls.topX.valueHandle; + ddTopY = ui.controls.topY.valueHandle; + cbTopGrid = ui.controls.topGrid.valueHandle; + ddBotX = ui.controls.bottomX.valueHandle; + ddBotY = ui.controls.bottomY.valueHandle; + cbBotGrid = ui.controls.bottomGrid.valueHandle; + axTop = ui.controls.plotAxes.axesById.top; + axBottom = ui.controls.plotAxes.axesById.bottom; + cbTopHold = ui.controls.topHold.valueHandle; + cbTopTrim = ui.controls.topTrim.valueHandle; + cbBotHold = ui.controls.bottomHold.valueHandle; + cbBotTrim = ui.controls.bottomTrim.valueHandle; if debugLog.enabled - debugLog.attachTextLog(txtLog); debugLog.trace('CSC debug trace enabled.'); - debugLog.instrumentFigure(fig); end %% App callbacks, loading, refresh, and plotting - function onOpenFiles(~,~) - [files,path] = uigetfile({'*.DTA;*.dta','Gamry DTA files (*.DTA)'}, ... - 'Select Gamry DTA file(s)','MultiSelect','on'); - if isequal(files,0) + function onOpenFilesChosen(~, event) + if isempty(event.paths) addLog('Open file canceled.'); return; end - if ischar(files) || isstring(files) - files = {char(files)}; - end - filepaths = cellfun(@(f) fullfile(path,f), files, 'UniformOutput', false); - addFiles(filepaths); + addFiles(event.paths); end function onOpenFolder(~,~) @@ -159,12 +150,16 @@ function reloadSelectedFile() function refreshFileList() if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listSelection', {}); + labkit.ui.view.setListItems(ui, 'files', {}); txtLoaded.Value = 'No files loaded'; return; end - [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', {S.items.name}, S.current); - S.current = idx(1); + names = {S.items.name}; + labkit.ui.view.setListItems(ui, 'files', names); + if isempty(S.current) || S.current < 1 || S.current > numel(names) + S.current = 1; + end + lbFiles.Value = names{S.current}; txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); end @@ -195,7 +190,7 @@ function loadCurrentItem() if isempty(S.curves) ddCurve.Items = {'(none)'}; ddCurve.Value = '(none)'; - lblStatus.Text = 'No curve found'; + txtStatus.Value = 'No curve found'; addLog('No curve parsed.'); return; end @@ -207,7 +202,7 @@ function loadCurrentItem() ddCurve.Items = items; ddCurve.Value = items{1}; - lblStatus.Text = sprintf('Loaded %d curve(s)', numel(S.curves)); + txtStatus.Value = sprintf('Loaded %d curve(s)', numel(S.curves)); addLog(sprintf('Loaded %d curve(s) from %s.', numel(S.curves), item.name)); updateDropdowns(); @@ -224,7 +219,7 @@ function clearCurrentItem() txtScan.Value = ''; ddCurve.Items = {'(none)'}; ddCurve.Value = '(none)'; - lblStatus.Text = 'Ready'; + txtStatus.Value = 'Ready'; txtQct.Value = ''; txtQcv.Value = ''; txtDiff.Value = ''; @@ -252,13 +247,24 @@ function autoPresetAndRefresh() end function onSwapPlots() - tx = ddTopX.Value; ty = ddTopY.Value; - bx = ddBotX.Value; by = ddBotY.Value; - - if any(strcmp(ddTopX.Items,bx)), ddTopX.Value = bx; end - if any(strcmp(ddTopY.Items,by)), ddTopY.Value = by; end - if any(strcmp(ddBotX.Items,tx)), ddBotX.Value = tx; end - if any(strcmp(ddBotY.Items,ty)), ddBotY.Value = ty; end + tx = ddTopX.Value; + ty = ddTopY.Value; + tg = cbTopGrid.Value; + th = cbTopHold.Value; + tt = cbTopTrim.Value; + bx = ddBotX.Value; + by = ddBotY.Value; + + if any(strcmp(ddTopX.Items, bx)), ddTopX.Value = bx; end + if any(strcmp(ddTopY.Items, by)), ddTopY.Value = by; end + cbTopGrid.Value = cbBotGrid.Value; + cbTopHold.Value = cbBotHold.Value; + cbTopTrim.Value = cbBotTrim.Value; + if any(strcmp(ddBotX.Items, tx)), ddBotX.Value = tx; end + if any(strcmp(ddBotY.Items, ty)), ddBotY.Value = ty; end + cbBotGrid.Value = tg; + cbBotHold.Value = th; + cbBotTrim.Value = tt; addLog('Swapped top/bottom selections.'); refreshPlotsOnly(); @@ -380,11 +386,11 @@ function refreshCompare() if ~isempty(readout.logMessage) addLog(readout.logMessage); end - lblStatus.Text = readout.statusText; + txtStatus.Value = readout.statusText; end function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); + labkit.ui.view.appendLog(ui, 'appLog', msg); debugLog.append(msg); end diff --git a/apps/electrochem/csc/labkit_CSC_app.m b/apps/electrochem/csc/labkit_CSC_app.m index 3dccea6..7fe3462 100644 --- a/apps/electrochem/csc/labkit_CSC_app.m +++ b/apps/electrochem/csc/labkit_CSC_app.m @@ -37,7 +37,7 @@ % Application state container - fig = csc.ui.runApp(debugLog); + fig = csc.run(debugLog); if nargout >= 1 varargout{1} = fig; end diff --git a/apps/electrochem/eis/+eis/+ui/buildSpec.m b/apps/electrochem/eis/+eis/+ui/buildSpec.m new file mode 100644 index 0000000..40e1066 --- /dev/null +++ b/apps/electrochem/eis/+eis/+ui/buildSpec.m @@ -0,0 +1,104 @@ +% Expected caller: eis.ui.runApp. Inputs are axis labels and a callback struct +% whose fields are app-owned callback handles. Output is a data-only UI 2.0 +% workbench spec for the EIS Overlay app. +function spec = buildSpec(axisItems, callbacks) + + spec = labkit.ui.spec.app("eisOverlay", ... + "Gamry EIS Multi-DTA Plot GUI", ... + "position", [80 60 1500 900], ... + "leftWidth", 360, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("filesSection", "Files", { ... + labkit.ui.spec.pathPanel("files", "Files", ... + "mode", "multiFile", ... + "selectionMode", "multiple", ... + "chooseLabel", "Open DTA file(s)", ... + "clearLabel", "Clear all", ... + "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... + "status", "No files loaded", ... + "onChoose", callbackValue(callbacks, "openFilesChosen"), ... + "onClear", callbackValue(callbacks, "clearAll"), ... + "onSelectionChange", callbackValue(callbacks, "selectionChanged")), ... + labkit.ui.spec.actionGroup("fileActions", { ... + labkit.ui.spec.action("openFolder", ... + "Open folder recursively", callbackValue(callbacks, "openFolder")), ... + labkit.ui.spec.action("removeSelected", ... + "Remove selected", callbackValue(callbacks, "removeSelected")), ... + labkit.ui.spec.action("exportPlot", ... + "Export current plot CSV", callbackValue(callbacks, "exportCSV"))})}), ... + labkit.ui.spec.section("plotOptions", "Plot Options", { ... + labkit.ui.spec.field("xAxis", "X axis:", ... + "kind", "dropdown", ... + "items", axisItems, ... + "value", "Zreal (ohm)", ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("yAxis", "Y axis:", ... + "kind", "dropdown", ... + "items", axisItems, ... + "value", "-Zimag (ohm)", ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("lineWidth", "Line width:", ... + "kind", "spinner", ... + "value", 1.4, ... + "limits", [0.1 10], ... + "step", 0.1, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("markerSize", "Marker size:", ... + "kind", "spinner", ... + "value", 6, ... + "limits", [1 20], ... + "step", 1, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("showMarkers", "Show markers", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("logX", "Log X", ... + "kind", "checkbox", ... + "value", false, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("logY", "Log Y", ... + "kind", "checkbox", ... + "value", false, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("showLegend", "Legend", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged")), ... + labkit.ui.spec.field("showGrid", "Grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "plotOptionsChanged"))})}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("usageSection", "Usage", { ... + labkit.ui.spec.statusPanel("usage", "Usage", ... + "value", { ... + 'Usage:', ... + '1. Open one or more EIS .DTA files containing ZCURVE.', ... + '2. Choose any X and Y axis combination.', ... + '3. Use Zreal vs -Zimag for a Nyquist plot.', ... + '4. Use Freq vs Zmod or Zphz for Bode-style plots.', ... + '5. CSV export writes one shared row index with X/Y pairs per file.'})}), ... + labkit.ui.spec.section("summarySection", "Summary", { ... + labkit.ui.spec.statusPanel("summary", "Summary", ... + "value", {'No files loaded.'})})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log")})})}, ... + "workspace", labkit.ui.spec.workspace("plotWorkspace", "Plot", { ... + labkit.ui.spec.previewArea("plot", "EIS Overlay", ... + "layout", "single", ... + "axisIds", {'overlay'}, ... + "axisTitles", {'EIS Overlay'}, ... + "xLabels", {'Zreal (ohm)'}, ... + "yLabels", {'-Zimag (ohm)'})})); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/electrochem/eis/+eis/+ui/runApp.m b/apps/electrochem/eis/+eis/run.m similarity index 52% rename from apps/electrochem/eis/+eis/+ui/runApp.m rename to apps/electrochem/eis/+eis/run.m index df7c796..8165815 100644 --- a/apps/electrochem/eis/+eis/+ui/runApp.m +++ b/apps/electrochem/eis/+eis/run.m @@ -2,7 +2,7 @@ % Input is the debug context prepared by the public launcher. Output is the app % figure. Side effects are GUI creation, user-driven file I/O, exports, % plotting, and debug trace attachment exactly as in the original entrypoint body. -function fig = runApp(debugLog) +function fig = run(debugLog) %RUNEISAPP Build and run the app body. S = struct(); @@ -22,152 +22,41 @@ 'Idc (A)', ... 'Vdc (V)'}; - workbenchOpts = struct(); - workbenchOpts.rightTitle = 'Plot'; - workbenchOpts.rightGridSize = [1 1]; - workbenchOpts.rightRowHeight = {'1x'}; - workbenchOpts.rightRowSpacing = 8; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry EIS Multi-DTA Plot GUI', ... - 'position', [80 60 1500 900], ... - 'leftWidth', 360, ... - 'options', workbenchOpts)); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - right = ui.rightGrid; - - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onRemoveSelected = @onRemoveSelected; - fileCallbacks.onClearAll = @onClearAll; - fileCallbacks.onExport = @onExportCSV; - fileCallbacks.onSelectFile = @(~,~) refreshPlot(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'removeSelected', 'Remove selected', ... - 'clearAll', 'Clear all', ... - 'export', 'Export current plot CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks, ... - struct('showRemoveSelected', true, 'multiselect', 'on')); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - plotOptionsUi = labkit.ui.view.section(layFA, 'Plot Options', 2, [8 2]); - gp = plotOptionsUi.grid; - - [~, ddX] = labkit.ui.view.form(gp, struct( ... - 'kind', 'dropdown', ... - 'label', 'X axis:', ... - 'items', {axisItems}, ... - 'value', 'Zreal (ohm)', ... - 'callback', @(~,~) refreshPlot())); - - [~, ddY] = labkit.ui.view.form(gp, struct( ... - 'kind', 'dropdown', ... - 'label', 'Y axis:', ... - 'items', {axisItems}, ... - 'value', '-Zimag (ohm)', ... - 'callback', @(~,~) refreshPlot())); - - [~, edLineWidth] = labkit.ui.view.form(gp, struct( ... - 'kind', 'spinner', ... - 'label', 'Line width:', ... - 'value', 1.4, ... - 'limits', [0.1 10], ... - 'step', 0.1, ... - 'callback', @(~,~) refreshPlot())); - - [~, edMarkerSize] = labkit.ui.view.form(gp, struct( ... - 'kind', 'spinner', ... - 'label', 'Marker size:', ... - 'value', 6, ... - 'limits', [1 20], ... - 'step', 1, ... - 'callback', @(~,~) refreshPlot())); - - cbMarkers = uicheckbox(gp, ... - 'Text', 'Show markers', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbMarkers.Layout.Row = 5; - cbMarkers.Layout.Column = [1 2]; - - cbLogX = uicheckbox(gp, ... - 'Text', 'Log X', ... - 'Value', false, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbLogX.Layout.Row = 6; - cbLogX.Layout.Column = [1 2]; - - cbLogY = uicheckbox(gp, ... - 'Text', 'Log Y', ... - 'Value', false, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbLogY.Layout.Row = 7; - cbLogY.Layout.Column = [1 2]; - - row8 = uigridlayout(gp, [1 2]); - row8.Layout.Row = 8; - row8.Layout.Column = [1 2]; - row8.ColumnWidth = {'1x', '1x'}; - row8.RowHeight = {'fit'}; - row8.Padding = [0 0 0 0]; - row8.ColumnSpacing = 8; - - cbLegend = uicheckbox(row8, ... - 'Text', 'Legend', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbGrid = uicheckbox(row8, ... - 'Text', 'Grid', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - - infoUi = labkit.ui.view.panel(laySR, 'text', 'Usage', 1, { ... - 'Usage:', ... - '1. Open one or more EIS .DTA files containing ZCURVE.', ... - '2. Choose any X and Y axis combination.', ... - '3. Use Zreal vs -Zimag for a Nyquist plot.', ... - '4. Use Freq vs Zmod or Zphz for Bode-style plots.', ... - '5. CSV export writes one shared row index with X/Y pairs per file.'}); - txtInfo = infoUi.textArea; - - logUi = labkit.ui.view.panel(layLog, 'log', 1); - txtLog = logUi.textArea; - - ax = labkit.ui.view.axes(right, 1, 'EIS Overlay', 'Zreal (ohm)', '-Zimag (ohm)'); - - txtSummary = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(txtSummary, laySR, 2); + callbacks = struct( ... + "openFilesChosen", @onOpenFilesChosen, ... + "openFolder", @onOpenFolder, ... + "removeSelected", @onRemoveSelected, ... + "clearAll", @onClearAll, ... + "exportCSV", @onExportCSV, ... + "selectionChanged", @(~,~) refreshPlot(), ... + "plotOptionsChanged", @(~,~) refreshPlot()); + spec = eis.ui.buildSpec(axisItems, callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + fig = ui.figure; + lbFiles = ui.controls.files.listbox; + txtLoaded = ui.controls.files.status; + ddX = ui.controls.xAxis.valueHandle; + ddY = ui.controls.yAxis.valueHandle; + edLineWidth = ui.controls.lineWidth.valueHandle; + edMarkerSize = ui.controls.markerSize.valueHandle; + cbMarkers = ui.controls.showMarkers.valueHandle; + cbLogX = ui.controls.logX.valueHandle; + cbLogY = ui.controls.logY.valueHandle; + cbLegend = ui.controls.showLegend.valueHandle; + cbGrid = ui.controls.showGrid.valueHandle; + ax = ui.controls.plot.axesById.overlay; + txtSummary = ui.controls.summary.textArea; txtSummary.Value = {'No files loaded.'}; if debugLog.enabled - debugLog.attachTextLog(txtLog); debugLog.trace('EIS debug trace enabled.'); - debugLog.instrumentFigure(fig); end %% App callbacks, session actions, refresh, and export - function onOpenFiles(~, ~) - [f, p] = uigetfile( ... - {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - 'Select one or more Gamry EIS DTA files', ... - 'MultiSelect', 'on'); - if isequal(f, 0) + function onOpenFilesChosen(~, event) + if isempty(event.paths) addLog('Open cancelled.'); return; end - - if ischar(f) || isstring(f) - f = {char(f)}; - end - - filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); - loadFiles(filepaths); + loadFiles(event.paths); end function onOpenFolder(~, ~) @@ -239,11 +128,11 @@ function onClearAll(~, ~) function refreshFileList() if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listItems', {}); + labkit.ui.view.setListItems(ui, 'files', {}); txtLoaded.Value = 'No files loaded'; return; end - labkit.ui.view.update(lbFiles, 'listItems', {S.items.name}); + labkit.ui.view.setListItems(ui, 'files', {S.items.name}); txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); end @@ -301,7 +190,7 @@ function onExportCSV(~, ~) end function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); + labkit.ui.view.appendLog(ui, 'appLog', msg); debugLog.append(msg); end end diff --git a/apps/electrochem/eis/labkit_EIS_app.m b/apps/electrochem/eis/labkit_EIS_app.m index f7594cc..067e591 100644 --- a/apps/electrochem/eis/labkit_EIS_app.m +++ b/apps/electrochem/eis/labkit_EIS_app.m @@ -17,7 +17,7 @@ error('labkit_EIS_app:TooManyOutputs', 'labkit_EIS_app returns at most the app figure handle.'); end - fig = eis.ui.runApp(debugLog); + fig = eis.run(debugLog); if nargout >= 1 varargout{1} = fig; end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+ui/buildSpec.m b/apps/electrochem/vt_resistance/+vt_resistance/+ui/buildSpec.m new file mode 100644 index 0000000..b53659a --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+ui/buildSpec.m @@ -0,0 +1,143 @@ +% Expected caller: vt_resistance.ui.runApp. Input is a callback struct whose +% fields are app-owned callback handles. Output is a data-only UI 2.0 workbench +% spec for the VT Resistance app. +function spec = buildSpec(callbacks) + + pulseModes = {'Metadata first, then auto', 'Metadata only', 'Auto from Im only'}; + steadyWindows = {'Full pulse median', 'Center 60% median'}; + voltageModes = {'Baseline-corrected dV/I', 'Raw Vf/I'}; + xChoices = {'Time (s)', 'Sample #'}; + yChoices = {'VT: Vf vs time', 'IT: Im vs time'}; + + spec = labkit.ui.spec.app("vtResistance", ... + "Gamry VT Steady Resistance GUI", ... + "position", [40 30 1680 980], ... + "leftWidth", 430, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("filesSection", "Files", { ... + labkit.ui.spec.pathPanel("files", "Files", ... + "mode", "multiFile", ... + "selectionMode", "single", ... + "chooseLabel", "Open DTA file(s)", ... + "clearLabel", "Clear all", ... + "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... + "status", "No files loaded", ... + "emptyText", "No files loaded", ... + "onChoose", callbackValue(callbacks, "openFilesChosen"), ... + "onClear", callbackValue(callbacks, "clearAll"), ... + "onSelectionChange", callbackValue(callbacks, "fileSelectionChanged")), ... + labkit.ui.spec.actionGroup("fileActions", { ... + labkit.ui.spec.action("openFolder", ... + "Open folder recursively", callbackValue(callbacks, "openFolder")), ... + labkit.ui.spec.action("exportResults", ... + "Export results CSV", callbackValue(callbacks, "exportResults"))})}), ... + labkit.ui.spec.section("analysisSettings", "Analysis Settings", { ... + labkit.ui.spec.field("pulseMode", "Pulse detection:", ... + "kind", "dropdown", ... + "items", pulseModes, ... + "value", pulseModes{1}, ... + "onChange", callbackValue(callbacks, "analysisChanged")), ... + labkit.ui.spec.field("steadyWindow", "Steady window:", ... + "kind", "dropdown", ... + "items", steadyWindows, ... + "value", steadyWindows{1}, ... + "onChange", callbackValue(callbacks, "analysisChanged")), ... + labkit.ui.spec.field("voltageMode", "Resistance voltage:", ... + "kind", "dropdown", ... + "items", voltageModes, ... + "value", voltageModes{1}, ... + "onChange", callbackValue(callbacks, "analysisChanged"))}), ... + labkit.ui.spec.section("plotDebug", "Plot / Debug", { ... + labkit.ui.spec.actionGroup("plotActions", { ... + labkit.ui.spec.action("reanalyzeFile", ... + "Re-analyze file", callbackValue(callbacks, "reanalyzeFile")), ... + labkit.ui.spec.action("refreshPlots", ... + "Refresh plots", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.action("swapPlots", ... + "Swap top / bottom", callbackValue(callbacks, "swapPlots")), ... + labkit.ui.spec.action("resetAxes", ... + "Reset axes", callbackValue(callbacks, "resetAxes"))}), ... + labkit.ui.spec.field("showMarkers", "Show markers", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("showShading", "Shade windows", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots"))}), ... + labkit.ui.spec.section("plotSelections", "Plot Selections", { ... + labkit.ui.spec.field("topX", "Top X:", ... + "kind", "dropdown", ... + "items", xChoices, ... + "value", "Time (s)", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("topY", "Top Y:", ... + "kind", "dropdown", ... + "items", yChoices, ... + "value", "VT: Vf vs time", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("topGrid", "Grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("bottomX", "Bottom X:", ... + "kind", "dropdown", ... + "items", xChoices, ... + "value", "Time (s)", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("bottomY", "Bottom Y:", ... + "kind", "dropdown", ... + "items", yChoices, ... + "value", "IT: Im vs time", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("bottomGrid", "Grid", ... + "kind", "checkbox", ... + "value", true, ... + "onChange", callbackValue(callbacks, "refreshPlots"))})}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("currentSummary", "Current File Summary", { ... + summaryField("controlMode", "Control mode:"), ... + summaryField("detect", "Detection:"), ... + summaryField("window", "Window:"), ... + summaryField("cathIV", "Cathodic I / Vss:"), ... + summaryField("anodIV", "Anodic I / Vss:"), ... + summaryField("cathBase", "Cathodic baseline:"), ... + summaryField("anodBase", "Anodic baseline:"), ... + summaryField("cathBaseWindow", "Cath baseline window:"), ... + summaryField("anodBaseWindow", "Anod baseline window:"), ... + summaryField("cathR", "Cathodic R:"), ... + summaryField("anodR", "Anodic R:"), ... + summaryField("averageR", "Average R:"), ... + summaryField("status", "Status:")}), ... + labkit.ui.spec.section("batchResults", "Batch Results", { ... + labkit.ui.spec.resultTable("results", "Batch Results", ... + "columns", {'File','Ic(A)','Ia(A)','Vc_ss(V)', ... + 'Va_ss(V)','R_cath(ohm)','R_anod(ohm)', ... + 'R_avg(ohm)','Detection'}, ... + "data", cell(0, 9))})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log")})})}, ... + "workspace", labkit.ui.spec.workspace("plots", "Plots", { ... + labkit.ui.spec.previewArea("plotAxes", "Plots", ... + "layout", "stack", ... + "count", 2, ... + "axisIds", {'top', 'bottom'}, ... + "axisTitles", {'Top Plot', 'Bottom Plot'})}, ... + "rowSpacing", 10)); +end + +function spec = summaryField(id, labelText) + spec = labkit.ui.spec.field(id, labelText, ... + "kind", "readonly", ... + "value", "-"); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+ui/createRightAxesPair.m b/apps/electrochem/vt_resistance/+vt_resistance/+ui/createRightAxesPair.m deleted file mode 100644 index 6c176ab..0000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/+ui/createRightAxesPair.m +++ /dev/null @@ -1,46 +0,0 @@ -% App-owned VT Resistance right-side axes layout helper. Expected caller: -% vt_resistance.ui.runApp. Inputs are the shell UI struct, axes titles, and -% whether plot-control panels are needed. Output is the UI struct with -% top/bottom axes and panel fields. Side effects are limited to creating -% controls on the shell right grid. -function ui = createRightAxesPair(ui, topTitle, bottomTitle, showControls) -%CREATERIGHTAXESPAIR Create VT Resistance top/bottom plot axes. - - if showControls - ui.topControlsPanel = uipanel(ui.rightGrid, 'Title', topTitle); - ui.topControlsPanel.Layout.Row = 1; - ui.topAxes = createOneAxes(ui.rightGrid, 2, topTitle); - - ui.bottomControlsPanel = uipanel(ui.rightGrid, 'Title', bottomTitle); - ui.bottomControlsPanel.Layout.Row = 3; - ui.bottomAxes = createOneAxes(ui.rightGrid, 4, bottomTitle); - else - ui.topControlsPanel = []; - ui.bottomControlsPanel = []; - ui.topAxes = createOneAxes(ui.rightGrid, 1, topTitle); - ui.bottomAxes = createOneAxes(ui.rightGrid, 2, bottomTitle); - end -end - -function ax = createOneAxes(parent, row, titleText) - ax = uiaxes(parent); - ax.Layout.Row = row; - title(ax, titleText); - labkit.ui.view.draw(ax, 'popout'); - disableAxesInteractivity(ax); -end - -function disableAxesInteractivity(ax) - try - disableDefaultInteractivity(ax); - catch - end - try - ax.Interactions = []; - catch - end - try - ax.Toolbar.Visible = 'off'; - catch - end -end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+ui/topBottomPlotControls.m b/apps/electrochem/vt_resistance/+vt_resistance/+ui/topBottomPlotControls.m deleted file mode 100644 index e539784..0000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/+ui/topBottomPlotControls.m +++ /dev/null @@ -1,64 +0,0 @@ -% App-owned VT Resistance top/bottom plot controls helper. Expected caller: -% vt_resistance.ui.runApp. Inputs are parent panels, axis items, default -% selections, and a value-change callback. Output is a controls struct with -% handles plus setSelections/swapSelections closures. Side effects are limited -% to creating controls on the supplied panels. -function ui = topBottomPlotControls(topPanel, bottomPanel, xItems, yItems, topDefaults, bottomDefaults, valueChangedFcn) -%TOPBOTTOMPLOTCONTROLS Create VT Resistance top/bottom plot controls. - - if nargin < 7 - valueChangedFcn = []; - end - - ui = struct(); - [ui.topGrid, ui.topX, ui.topY, ui.topGridCheckbox] = createOneRow( ... - topPanel, xItems, yItems, topDefaults, valueChangedFcn); - [ui.bottomGrid, ui.bottomX, ui.bottomY, ui.bottomGridCheckbox] = createOneRow( ... - bottomPanel, xItems, yItems, bottomDefaults, valueChangedFcn); - ui.setSelections = @setSelections; - ui.swapSelections = @swapSelections; - - function setSelections(topSelection, bottomSelection) - applySelection(ui.topX, ui.topY, topSelection); - applySelection(ui.bottomX, ui.bottomY, bottomSelection); - end - - function swapSelections() - topSelection = struct('x', ui.topX.Value, 'y', ui.topY.Value); - bottomSelection = struct('x', ui.bottomX.Value, 'y', ui.bottomY.Value); - setSelections(bottomSelection, topSelection); - end -end - -function [grid, ddX, ddY, cbGrid] = createOneRow(parent, xItems, yItems, defaults, valueChangedFcn) - grid = uigridlayout(parent, [1 5]); - grid.ColumnWidth = {'fit', '1x', 'fit', '1x', '1x'}; - grid.Padding = [8 6 8 6]; - grid.ColumnSpacing = 8; - - uilabel(grid, 'Text', 'X:', 'HorizontalAlignment', 'right'); - ddX = uidropdown(grid, ... - 'Items', xItems, ... - 'Value', defaults.x, ... - 'ValueChangedFcn', valueChangedFcn); - - uilabel(grid, 'Text', 'Y:', 'HorizontalAlignment', 'right'); - ddY = uidropdown(grid, ... - 'Items', yItems, ... - 'Value', defaults.y, ... - 'ValueChangedFcn', valueChangedFcn); - - cbGrid = uicheckbox(grid, ... - 'Text', 'Grid', ... - 'Value', defaults.grid, ... - 'ValueChangedFcn', valueChangedFcn); -end - -function applySelection(ddX, ddY, selection) - if isfield(selection, 'x') && any(strcmp(ddX.Items, selection.x)) - ddX.Value = selection.x; - end - if isfield(selection, 'y') && any(strcmp(ddY.Items, selection.y)) - ddY.Value = selection.y; - end -end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+ui/runApp.m b/apps/electrochem/vt_resistance/+vt_resistance/run.m similarity index 64% rename from apps/electrochem/vt_resistance/+vt_resistance/+ui/runApp.m rename to apps/electrochem/vt_resistance/+vt_resistance/run.m index 362357a..143d915 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+ui/runApp.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/run.m @@ -2,7 +2,7 @@ % Input is the debug context prepared by the public launcher. Output is the app % figure. Side effects are GUI creation, user-driven file I/O, exports, % plotting, and debug trace attachment exactly as in the original entrypoint body. -function fig = runApp(debugLog) +function fig = run(debugLog) %RUNVTRESISTANCEAPP Build and run the app body. S = struct(); @@ -10,152 +10,73 @@ S.items = S.session.items; S.current = []; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry VT Steady Resistance GUI', ... - 'position', [40 30 1680 980], ... - 'leftWidth', 430, ... - 'options', struct( ... - 'rightTitle', 'Plots', ... - 'rightGridSize', [4 1], ... - 'rightRowHeight', {{'fit', '1x', 'fit', '1x'}}, ... - 'rightRowSpacing', 10))); - ui = vt_resistance.ui.createRightAxesPair(ui, 'Top Plot', 'Bottom Plot', true); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onClearAll = @(~,~) clearAllFiles(); - fileCallbacks.onExport = @(~,~) exportResultsCSV(); - fileCallbacks.onSelectFile = @(~,~) onSelectFile(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'clearAll', 'Clear all', ... - 'export', 'Export results CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - settingsUi = labkit.ui.view.section(layFA, 'Analysis Settings', 2, [3 2]); - gs = settingsUi.grid; - - uilabel(gs,'Text','Pulse detection:','HorizontalAlignment','right'); - ddPulseMode = uidropdown(gs, ... - 'Items',{'Metadata first, then auto','Metadata only','Auto from Im only'}, ... - 'Value','Metadata first, then auto', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - ddPulseMode.Layout.Row = 1; - ddPulseMode.Layout.Column = 2; - - uilabel(gs,'Text','Steady window:','HorizontalAlignment','right'); - ddSteadyWindow = uidropdown(gs, ... - 'Items',{'Full pulse median','Center 60% median'}, ... - 'Value','Full pulse median', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - ddSteadyWindow.Layout.Row = 2; - ddSteadyWindow.Layout.Column = 2; - - uilabel(gs,'Text','Resistance voltage:','HorizontalAlignment','right'); - ddVoltageMode = uidropdown(gs, ... - 'Items',{'Baseline-corrected dV/I','Raw Vf/I'}, ... - 'Value','Baseline-corrected dV/I', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - ddVoltageMode.Layout.Row = 3; - ddVoltageMode.Layout.Column = 2; - - actionUi = labkit.ui.view.section(layFA, 'Plot / Debug', 3, [2 3]); - ga = actionUi.grid; - - btnReanalyze = uibutton(ga,'Text','Re-analyze file','ButtonPushedFcn',@(~,~) analyzeCurrentFile()); - btnReanalyze.Layout.Row = 1; btnReanalyze.Layout.Column = 1; - btnRefresh = uibutton(ga,'Text','Refresh plots','ButtonPushedFcn',@(~,~) refreshPlots()); - btnRefresh.Layout.Row = 1; btnRefresh.Layout.Column = 2; - btnSwap = uibutton(ga,'Text','Swap top / bottom','ButtonPushedFcn',@(~,~) swapPlots()); - btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 3; - - btnReset = uibutton(ga,'Text','Reset axes','ButtonPushedFcn',@(~,~) resetAxes()); - btnReset.Layout.Row = 2; btnReset.Layout.Column = 1; - cbShowMarkers = uicheckbox(ga,'Text','Show markers','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); - cbShowMarkers.Layout.Row = 2; cbShowMarkers.Layout.Column = 2; - cbShowShading = uicheckbox(ga,'Text','Shade windows','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); - cbShowShading.Layout.Row = 2; cbShowShading.Layout.Column = 3; - - infoUi = labkit.ui.view.section(laySR, 'Current File Summary', 1, [13 2]); - gi = infoUi.grid; - - S.txtControlMode = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 1, 'label', 'Control mode:')); - S.txtDetect = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 2, 'label', 'Detection:')); - S.txtWindow = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 3, 'label', 'Window:')); - S.txtCathIV = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 4, 'label', 'Cathodic I / Vss:')); - S.txtAnodIV = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 5, 'label', 'Anodic I / Vss:')); - S.txtCathBase = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 6, 'label', 'Cathodic baseline:')); - S.txtAnodBase = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 7, 'label', 'Anodic baseline:')); - S.txtCathBaseWin = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 8, 'label', 'Cath baseline window:')); - S.txtAnodBaseWin = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 9, 'label', 'Anod baseline window:')); - S.txtCathR = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 10, 'label', 'Cathodic R:')); - S.txtAnodR = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 11, 'label', 'Anodic R:')); - S.txtAvgR = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 12, 'label', 'Average R:')); - S.txtStatus = labkit.ui.view.form(gi, struct('kind', 'info', 'row', 13, 'label', 'Status:')); - - tableUi = labkit.ui.view.panel(laySR, 'table', 'Batch Results', 2, ... - {'File','Ic(A)','Ia(A)','Vc_ss(V)','Va_ss(V)','R_cath(ohm)','R_anod(ohm)','R_avg(ohm)','Detection'}, ... - cell(0,9)); - tbl = tableUi.table; - - logUi = labkit.ui.view.panel(layLog, 'log', 1); - txtLog = logUi.textArea; - + callbacks = struct( ... + "openFilesChosen", @onOpenFilesChosen, ... + "openFolder", @onOpenFolder, ... + "clearAll", @(~,~) clearAllFiles(), ... + "exportResults", @(~,~) exportResultsCSV(), ... + "fileSelectionChanged", @(~,~) onSelectFile(), ... + "analysisChanged", @(~,~) analyzeCurrentFile(), ... + "reanalyzeFile", @(~,~) analyzeCurrentFile(), ... + "refreshPlots", @(~,~) refreshPlots(), ... + "swapPlots", @(~,~) swapPlots(), ... + "resetAxes", @(~,~) resetAxes()); + spec = vt_resistance.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + fig = ui.figure; + lbFiles = ui.controls.files.listbox; + txtLoaded = ui.controls.files.status; + ddPulseMode = ui.controls.pulseMode.valueHandle; + ddSteadyWindow = ui.controls.steadyWindow.valueHandle; + ddVoltageMode = ui.controls.voltageMode.valueHandle; + cbShowMarkers = ui.controls.showMarkers.valueHandle; + cbShowShading = ui.controls.showShading.valueHandle; + S.txtControlMode = ui.controls.controlMode.valueHandle; + S.txtDetect = ui.controls.detect.valueHandle; + S.txtWindow = ui.controls.window.valueHandle; + S.txtCathIV = ui.controls.cathIV.valueHandle; + S.txtAnodIV = ui.controls.anodIV.valueHandle; + S.txtCathBase = ui.controls.cathBase.valueHandle; + S.txtAnodBase = ui.controls.anodBase.valueHandle; + S.txtCathBaseWin = ui.controls.cathBaseWindow.valueHandle; + S.txtAnodBaseWin = ui.controls.anodBaseWindow.valueHandle; + S.txtCathR = ui.controls.cathR.valueHandle; + S.txtAnodR = ui.controls.anodR.valueHandle; + S.txtAvgR = ui.controls.averageR.valueHandle; + S.txtStatus = ui.controls.status.valueHandle; + tbl = ui.controls.results.table; topPlotDefaults = struct('x', 'Time (s)', 'y', 'VT: Vf vs time', 'grid', true); bottomPlotDefaults = struct('x', 'Time (s)', 'y', 'IT: Im vs time', 'grid', true); - plotControls = vt_resistance.ui.topBottomPlotControls( ... - ui.topControlsPanel, ... - ui.bottomControlsPanel, ... - {'Time (s)', 'Sample #'}, ... - {'VT: Vf vs time', 'IT: Im vs time'}, ... - topPlotDefaults, ... - bottomPlotDefaults, ... - @(~,~) refreshPlots()); - ddTopX = plotControls.topX; - ddTopY = plotControls.topY; - cbTopGrid = plotControls.topGridCheckbox; - axTop = ui.topAxes; - ddBotX = plotControls.bottomX; - ddBotY = plotControls.bottomY; - cbBotGrid = plotControls.bottomGridCheckbox; - axBottom = ui.bottomAxes; + ddTopX = ui.controls.topX.valueHandle; + ddTopY = ui.controls.topY.valueHandle; + cbTopGrid = ui.controls.topGrid.valueHandle; + axTop = ui.controls.plotAxes.axesById.top; + ddBotX = ui.controls.bottomX.valueHandle; + ddBotY = ui.controls.bottomY.valueHandle; + cbBotGrid = ui.controls.bottomGrid.valueHandle; + axBottom = ui.controls.plotAxes.axesById.bottom; if debugLog.enabled - debugLog.attachTextLog(txtLog); debugLog.trace('VT resistance debug trace enabled.'); - debugLog.instrumentFigure(fig); end %% App callbacks, session actions, refresh, plotting, and export - function onOpenFiles(~,~) - [files,path] = uigetfile({'*.DTA;*.dta','Gamry DTA files (*.DTA)'}, ... - 'Select Gamry DTA file(s)','MultiSelect','on'); - if isequal(files,0) + function onOpenFilesChosen(~, event) + if isempty(event.paths) + addLog('Open cancelled.'); return; end - if ischar(files) - files = {files}; - end - filepaths = cellfun(@(f) fullfile(path,f), files, 'UniformOutput', false); - addFiles(filepaths); + addFiles(event.paths); end function onOpenFolder(~,~) folder = uigetdir(pwd,'Select folder containing DTA files'); if isequal(folder,0) + addLog('Folder selection cancelled.'); return; end filepaths = labkit.dta.findFiles(folder); if isempty(filepaths) uialert(fig,'No .DTA files found in the selected folder.','Open folder'); + addLog(['No .DTA files found under: ' folder]); return; end addFiles(filepaths); @@ -176,6 +97,12 @@ function addFiles(filepaths) refreshBatchTable(); refreshResultsSummary(); refreshPlots(); + + if ~isempty(report.failed) + firstError = report.failed(1); + uialert(fig, sprintf('Failed to load:\n%s\n\n%s', ... + firstError.filepath, firstError.message), 'Load error'); + end end function postProcessAddedItems(filepaths) @@ -260,24 +187,27 @@ function clearAllFiles() function refreshFileList() if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listSelection', {}); - txtLoaded.Value = fileLabels.loadedText; + labkit.ui.view.setListItems(ui, 'files', {}); + txtLoaded.Value = 'No files loaded'; S.current = []; return; end names = {S.items.name}; - [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', names, S.current); - S.current = idx(1); + labkit.ui.view.setListItems(ui, 'files', names); + if isempty(S.current) || S.current < 1 || S.current > numel(names) + S.current = 1; + end + lbFiles.Value = names{S.current}; txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); end function refreshBatchTable() if isempty(S.items) - tbl.Data = cell(0,9); + tbl.Data = cell(0, 9); return; end - tbl.Data = vt_resistance.view.buildBatchTableData(S.items); + tbl.Data = vt_resistance.view.buildBatchTableData(S.items); end function refreshResultsSummary() @@ -341,8 +271,8 @@ function refreshResultsSummary() end function refreshPlots() - labkit.ui.view.draw(axTop, 'clear'); - labkit.ui.view.draw(axBottom, 'clear'); + clearAxis(axTop); + clearAxis(axBottom); if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) title(axTop,'Top Plot'); title(axBottom,'Bottom Plot'); @@ -446,7 +376,15 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) end function swapPlots() - plotControls.swapSelections(); + topX = ddTopX.Value; + topY = ddTopY.Value; + topGrid = cbTopGrid.Value; + ddTopX.Value = ddBotX.Value; + ddTopY.Value = ddBotY.Value; + cbTopGrid.Value = cbBotGrid.Value; + ddBotX.Value = topX; + ddBotY.Value = topY; + cbBotGrid.Value = topGrid; refreshPlots(); end @@ -456,12 +394,17 @@ function resetAxes() end function restoreDefaultPlotSelections() - plotControls.setSelections(topPlotDefaults, bottomPlotDefaults); + ddTopX.Value = topPlotDefaults.x; + ddTopY.Value = topPlotDefaults.y; + cbTopGrid.Value = topPlotDefaults.grid; + ddBotX.Value = bottomPlotDefaults.x; + ddBotY.Value = bottomPlotDefaults.y; + cbBotGrid.Value = bottomPlotDefaults.grid; end function resetAxesToDefaultState() - labkit.ui.view.draw(axTop, 'reset', 'Top Plot'); - labkit.ui.view.draw(axBottom, 'reset', 'Bottom Plot'); + resetAxis(axTop, 'Top Plot'); + resetAxis(axBottom, 'Bottom Plot'); end function exportResultsCSV() @@ -483,8 +426,19 @@ function exportResultsCSV() end function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); + labkit.ui.view.appendLog(ui, 'appLog', msg); debugLog.append(msg); end end + +function clearAxis(ax) + cla(ax); +end + +function resetAxis(ax, titleText) + cla(ax); + title(ax, titleText); + xlabel(ax, ''); + ylabel(ax, ''); +end diff --git a/apps/electrochem/vt_resistance/labkit_VTResistance_app.m b/apps/electrochem/vt_resistance/labkit_VTResistance_app.m index b6fc102..3fab642 100644 --- a/apps/electrochem/vt_resistance/labkit_VTResistance_app.m +++ b/apps/electrochem/vt_resistance/labkit_VTResistance_app.m @@ -25,7 +25,7 @@ error('labkit_VTResistance_app:TooManyOutputs', 'labkit_VTResistance_app returns at most the app figure handle.'); end - fig = vt_resistance.ui.runApp(debugLog); + fig = vt_resistance.run(debugLog); if nargout >= 1 varargout{1} = fig; end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+ui/buildSpec.m b/apps/image_measurement/batch_crop/+batch_crop/+ui/buildSpec.m new file mode 100644 index 0000000..3717d81 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+ui/buildSpec.m @@ -0,0 +1,130 @@ +% Expected caller: labkit_BatchImageCrop_app. Inputs are the initial output +% folder and app-owned callback handles. Output is a data-only UI 2.0 +% workbench spec for the Batch Image Crop app. +function spec = buildSpec(initialOutputFolder, callbacks) + + spec = labkit.ui.spec.app("batchCropApp", ... + "Microscope Batch Image Crop", ... + "position", [80 60 1440 860], ... + "leftWidth", 400, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("imagesSection", "Images", { ... + labkit.ui.spec.pathPanel("images", "Images", ... + "mode", "multiFile", ... + "selectionMode", "single", ... + "chooseLabel", "Open image files", ... + "clearLabel", "Clear images", ... + "filters", batch_crop.io.imageDialogFilter(), ... + "dialogTitle", "Select microscope images", ... + "status", "No images loaded", ... + "emptyText", "No images loaded", ... + "onChoose", callbackValue(callbacks, "imagesChosen"), ... + "onClear", callbackValue(callbacks, "clearImages"), ... + "onSelectionChange", callbackValue(callbacks, ... + "imageSelectionChanged")), ... + labkit.ui.spec.actionGroup("imageNavigation", { ... + labkit.ui.spec.action("previousImage", ... + "Previous image", callbackValue(callbacks, ... + "previousImage"), "enabled", false), ... + labkit.ui.spec.action("nextImage", ... + "Next image", callbackValue(callbacks, ... + "nextImage"), "enabled", false)}), ... + labkit.ui.spec.field("imageSource", "Current image", ... + "kind", "readonly", ... + "value", "No images loaded"), ... + labkit.ui.spec.field("imageStatus", "Status", ... + "kind", "readonly", ... + "value", "Images: 0")}, ... + "height", 250), ... + labkit.ui.spec.section("cropGeometry", "Crop Geometry", { ... + labkit.ui.spec.field("cropWidth", "Width (px):", ... + "kind", "spinner", ... + "value", 1024, ... + "limits", [1 Inf], ... + "step", 1, ... + "onChange", callbackValue(callbacks, ... + "cropGeometryChanged")), ... + labkit.ui.spec.field("cropHeight", "Height (px):", ... + "kind", "spinner", ... + "value", 1024, ... + "limits", [1 Inf], ... + "step", 1, ... + "onChange", callbackValue(callbacks, ... + "cropGeometryChanged")), ... + labkit.ui.spec.field("rotation", "Rotation (deg):", ... + "kind", "spinner", ... + "value", 0, ... + "limits", [-180 180], ... + "step", 0.5, ... + "enabled", false, ... + "onChange", callbackValue(callbacks, "rotationChanged")), ... + labkit.ui.spec.field("fillMode", "Fill:", ... + "kind", "dropdown", ... + "items", {'Black', 'White'}, ... + "value", "Black", ... + "onChange", callbackValue(callbacks, "fillModeChanged")), ... + labkit.ui.spec.field("centerX", "Center X:", ... + "kind", "spinner", ... + "value", 1, ... + "limits", [1 Inf], ... + "step", 1, ... + "enabled", false, ... + "onChange", callbackValue(callbacks, "centerChanged")), ... + labkit.ui.spec.field("centerY", "Center Y:", ... + "kind", "spinner", ... + "value", 1, ... + "limits", [1 Inf], ... + "step", 1, ... + "enabled", false, ... + "onChange", callbackValue(callbacks, "centerChanged")), ... + labkit.ui.spec.action("useCanvasCenter", ... + "Use canvas center", callbackValue(callbacks, ... + "useCanvasCenter"), "enabled", false)}, ... + "height", 260), ... + labkit.ui.spec.section("exportSection", "Export", { ... + labkit.ui.spec.field("format", "Format:", ... + "kind", "dropdown", ... + "items", {'PNG', 'TIFF', 'JPEG'}, ... + "value", "PNG", ... + "onChange", callbackValue(callbacks, ... + "exportSettingChanged")), ... + labkit.ui.spec.field("outputFolder", "Output folder", ... + "kind", "readonly", ... + "value", char(initialOutputFolder)), ... + labkit.ui.spec.actionGroup("exportActions", { ... + labkit.ui.spec.action("chooseOutputFolder", ... + "Choose export folder", callbackValue(callbacks, ... + "chooseOutputFolder")), ... + labkit.ui.spec.action("exportCrops", ... + "Export cropped images", callbackValue(callbacks, ... + "exportCrops"), "enabled", false)})}, ... + "height", 145)}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("summarySection", "Summary", { ... + labkit.ui.spec.resultTable("resultTable", ... + "Batch Results", ... + "columns", {'Metric', 'Value'}, ... + "data", {'Images loaded', '0'}), ... + labkit.ui.spec.statusPanel("details", "Details", ... + "value", {'Load microscope images to begin.'})})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log", ... + "value", {'Ready.'})})})}, ... + "workspace", labkit.ui.spec.workspace("previewWorkspace", ... + "Crop Preview", { ... + labkit.ui.spec.previewArea("preview", ... + "Rotated preview + fixed crop", ... + "layout", "single", ... + "axisIds", {'crop'}, ... + "axisTitles", {'Rotated preview + fixed crop'})})); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+ui/createControls.m b/apps/image_measurement/batch_crop/+batch_crop/+ui/createControls.m deleted file mode 100644 index f33fb78..0000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+ui/createControls.m +++ /dev/null @@ -1,171 +0,0 @@ -% App-owned GUI construction helper. Expected caller: -% labkit_BatchImageCrop_app during startup. Inputs are shell tab grids, -% initial output folder, and callback handles. Output is a struct of UI -% handles. This helper creates controls only and has no file side effects. -function controls = createControls(layFA, laySR, layLog, initialOutputFolder, callbacks) -%CREATECONTROLS Create controls for the batch image crop app. - - filePanel = labkit.ui.view.section(layFA, 'Images', 1, [5 2], ... - struct('rowHeight', {{'fit', 'fit', 105, 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - fileGrid = filePanel.grid; - - controls.btnOpenFiles = uibutton(fileGrid, 'Text', 'Open image files', ... - 'ButtonPushedFcn', callbacks.onOpenFiles); - controls.btnOpenFiles.Layout.Row = 1; - controls.btnOpenFiles.Layout.Column = 1; - - controls.btnClearImages = uibutton(fileGrid, 'Text', 'Clear images', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.onClearImages); - controls.btnClearImages.Layout.Row = 1; - controls.btnClearImages.Layout.Column = 2; - - controls.txtImageSource = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No images loaded')); - controls.txtImageSource.Layout.Row = 2; - controls.txtImageSource.Layout.Column = [1 2]; - - controls.lbImages = uilistbox(fileGrid, ... - 'Items', {'No images loaded'}, ... - 'ValueChangedFcn', callbacks.onImageSelectionChanged); - controls.lbImages.Layout.Row = 3; - controls.lbImages.Layout.Column = [1 2]; - - controls.btnPrevious = uibutton(fileGrid, 'Text', 'Previous image', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.onPreviousImage); - controls.btnPrevious.Layout.Row = 4; - controls.btnPrevious.Layout.Column = 1; - - controls.btnNext = uibutton(fileGrid, 'Text', 'Next image', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.onNextImage); - controls.btnNext.Layout.Row = 4; - controls.btnNext.Layout.Column = 2; - - controls.txtImageStatus = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'Images: 0')); - controls.txtImageStatus.Layout.Row = 5; - controls.txtImageStatus.Layout.Column = [1 2]; - - cropRows = repmat({'fit'}, 1, 7); - cropPanel = labkit.ui.view.section(layFA, 'Crop Geometry', 2, [7 2], ... - struct('rowHeight', {cropRows}, 'columnWidth', {{145, '1x'}})); - cropGrid = cropPanel.grid; - - [lblWidth, controls.edtCropWidth] = labkit.ui.view.form(cropGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Width (px):', ... - 'value', 1024, ... - 'limits', [1 Inf], ... - 'step', 1, ... - 'callback', callbacks.onCropGeometryChanged)); - placeLabeled(lblWidth, controls.edtCropWidth, 1); - - [lblHeight, controls.edtCropHeight] = labkit.ui.view.form(cropGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Height (px):', ... - 'value', 1024, ... - 'limits', [1 Inf], ... - 'step', 1, ... - 'callback', callbacks.onCropGeometryChanged)); - placeLabeled(lblHeight, controls.edtCropHeight, 2); - - [lblRotation, controls.edtRotation] = labkit.ui.view.form(cropGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Rotation (deg):', ... - 'value', 0, ... - 'limits', [-180 180], ... - 'step', 0.5, ... - 'callback', callbacks.onRotationChanged)); - placeLabeled(lblRotation, controls.edtRotation, 3); - - [lblFill, controls.ddFillMode] = labkit.ui.view.form(cropGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Fill:', ... - 'items', {{'Black', 'White'}}, ... - 'value', 'Black', ... - 'callback', callbacks.onFillModeChanged)); - placeLabeled(lblFill, controls.ddFillMode, 4); - - [lblCenterX, controls.edtCenterX] = labkit.ui.view.form(cropGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Center X:', ... - 'value', 1, ... - 'limits', [1 Inf], ... - 'step', 1, ... - 'enabled', false, ... - 'callback', callbacks.onCenterChanged)); - placeLabeled(lblCenterX, controls.edtCenterX, 5); - - [lblCenterY, controls.edtCenterY] = labkit.ui.view.form(cropGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Center Y:', ... - 'value', 1, ... - 'limits', [1 Inf], ... - 'step', 1, ... - 'enabled', false, ... - 'callback', callbacks.onCenterChanged)); - placeLabeled(lblCenterY, controls.edtCenterY, 6); - - controls.btnUseCanvasCenter = uibutton(cropGrid, 'Text', 'Use canvas center', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.onUseCanvasCenter); - controls.btnUseCanvasCenter.Layout.Row = 7; - controls.btnUseCanvasCenter.Layout.Column = [1 2]; - - exportRows = repmat({'fit'}, 1, 4); - exportPanel = labkit.ui.view.section(layFA, 'Export', 3, [4 2], ... - struct('rowHeight', {exportRows}, 'columnWidth', {{145, '1x'}})); - exportGrid = exportPanel.grid; - - [lblFormat, controls.ddFormat] = labkit.ui.view.form(exportGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Format:', ... - 'items', {{'PNG', 'TIFF', 'JPEG'}}, ... - 'value', 'PNG', ... - 'callback', callbacks.onExportSettingChanged)); - lblFormat.Layout.Row = 1; - lblFormat.Layout.Column = 1; - controls.ddFormat.Layout.Row = 1; - controls.ddFormat.Layout.Column = 2; - - controls.txtOutputFolder = labkit.ui.view.form(exportGrid, struct( ... - 'kind', 'readonly', ... - 'value', char(initialOutputFolder))); - controls.txtOutputFolder.Layout.Row = 2; - controls.txtOutputFolder.Layout.Column = [1 2]; - - controls.btnChooseOutput = uibutton(exportGrid, 'Text', 'Choose export folder', ... - 'ButtonPushedFcn', callbacks.onChooseOutputFolder); - controls.btnChooseOutput.Layout.Row = 3; - controls.btnChooseOutput.Layout.Column = [1 2]; - - controls.btnExport = uibutton(exportGrid, 'Text', 'Export cropped images', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.onExportCrops); - controls.btnExport.Layout.Row = 4; - controls.btnExport.Layout.Column = [1 2]; - - controls.resultTable = uitable(laySR, ... - 'ColumnName', {'Metric', 'Value'}, ... - 'Data', {'Images loaded', '0'}); - controls.resultTable.Layout.Row = 1; - - controls.txtDetails = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(controls.txtDetails, laySR, 2); - controls.txtDetails.Value = {'Load microscope images to begin.'}; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - controls.txtLog = logUi.textArea; -end - -function placeLabeled(labelHandle, controlHandle, row) - labelHandle.Layout.Row = row; - labelHandle.Layout.Column = 1; - controlHandle.Layout.Row = row; - controlHandle.Layout.Column = 2; -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/run.m b/apps/image_measurement/batch_crop/+batch_crop/run.m new file mode 100644 index 0000000..b75e21f --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/run.m @@ -0,0 +1,423 @@ +% Expected caller: labkit_BatchImageCrop_app. Input is the debug context +% prepared by the public launcher. Output is the app figure. Side effects are +% GUI creation, user-driven image loading, crop export, and debug trace attachment. +function fig = run(debugLog) +%RUN Build and run the Batch Image Crop app body. + + S = struct(); + S.items = repmat(batch_crop.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.outputFolder = string(pwd); + S.lastExport = []; + + callbacks = struct( ... + "imagesChosen", @onImagesChosen, ... + "clearImages", @(~, ~) onClearImages(), ... + "imageSelectionChanged", @(~, ~) onImageSelectionChanged(), ... + "previousImage", @(~, ~) onPreviousImage(), ... + "nextImage", @(~, ~) onNextImage(), ... + "cropGeometryChanged", @(~, ~) onCropGeometryChanged(), ... + "rotationChanged", @(~, ~) onRotationChanged(), ... + "fillModeChanged", @(~, ~) onFillModeChanged(), ... + "centerChanged", @(~, ~) onCenterChanged(), ... + "useCanvasCenter", @(~, ~) onUseCanvasCenter(), ... + "exportSettingChanged", @(~, ~) onExportSettingChanged(), ... + "chooseOutputFolder", @(~, ~) onChooseOutputFolder(), ... + "exportCrops", @(~, ~) onExportCrops()); + spec = batch_crop.ui.buildSpec(S.outputFolder, callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + fig = ui.figure; + + previewAxes = ui.controls.preview.primaryAxes; + imageRuntime = labkit.ui.tool.createRuntime(previewAxes, ... + struct('figure', fig, 'onTrace', debugLog.trace)); + cropSession = imageRuntime.createSession(struct( ... + 'name', 'batchCropCenter', ... + 'onPointerDown', @onPreviewPointerDown, ... + 'installScrollWheel', false)); + + btnOpenFiles = ui.controls.images.chooseButton; + btnClearImages = ui.controls.images.clearButton; + txtImageSource = ui.controls.imageSource.valueHandle; + lbImages = ui.controls.images.listbox; + btnPrevious = ui.controls.previousImage.button; + btnNext = ui.controls.nextImage.button; + txtImageStatus = ui.controls.imageStatus.valueHandle; + edtCropWidth = ui.controls.cropWidth.valueHandle; + edtCropHeight = ui.controls.cropHeight.valueHandle; + edtRotation = ui.controls.rotation.valueHandle; + ddFillMode = ui.controls.fillMode.valueHandle; + edtCenterX = ui.controls.centerX.valueHandle; + edtCenterY = ui.controls.centerY.valueHandle; + btnUseCanvasCenter = ui.controls.useCanvasCenter.button; + ddFormat = ui.controls.format.valueHandle; + txtOutputFolder = ui.controls.outputFolder.valueHandle; + btnChooseOutput = ui.controls.chooseOutputFolder.button; + btnExport = ui.controls.exportCrops.button; + resultTable = ui.controls.resultTable.table; + txtDetails = ui.controls.details.textArea; + if debugLog.enabled + debugLog.trace('Batch image crop debug trace enabled.'); + end + + resetPreviewAxes(); + refreshAll(); + + function onImagesChosen(~, event) + if isempty(event.paths) + addLog('Image file selection cancelled.'); + return; + end + + try + items = readCropItems(event.paths); + catch ME + showError('Could not load images', ME.message); + return; + end + + S.items = items; + S.currentIndex = 1; + S.lastExport = []; + addLog(sprintf('Loaded %d image(s).', numel(S.items))); + refreshAll(); + end + + function onClearImages() + S.items = repmat(batch_crop.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.lastExport = []; + addLog('Cleared loaded images.'); + refreshAll(); + end + + function onImageSelectionChanged() + if isempty(S.items) + return; + end + items = batch_crop.view.listboxItems(S.items); + idx = find(strcmp(items, lbImages.Value), 1); + if isempty(idx) + return; + end + S.currentIndex = idx; + refreshAll(); + end + + function onPreviousImage() + if isempty(S.items) + return; + end + S.currentIndex = max(1, S.currentIndex - 1); + refreshAll(); + end + + function onNextImage() + if isempty(S.items) + return; + end + S.currentIndex = min(numel(S.items), S.currentIndex + 1); + refreshAll(); + end + + function onCropGeometryChanged() + edtCropWidth.Value = round(max(1, edtCropWidth.Value)); + edtCropHeight.Value = round(max(1, edtCropHeight.Value)); + refreshPreview(); + refreshSummary(); + end + + function onRotationChanged() + if ~hasCurrentImage() + return; + end + S.items(S.currentIndex).angleDeg = edtRotation.Value; + ensureCurrentCenter(); + addLog(sprintf('Updated rotation for image %d: %.3g deg.', ... + S.currentIndex, S.items(S.currentIndex).angleDeg)); + refreshAll(); + end + + function onFillModeChanged() + refreshPreview(); + refreshSummary(); + end + + function onCenterChanged() + if ~hasCurrentImage() + return; + end + S.items(S.currentIndex).centerXY = [edtCenterX.Value, edtCenterY.Value]; + S.items(S.currentIndex).centerSet = true; + addLog(sprintf('Set crop center for image %d: x=%.1f, y=%.1f.', ... + S.currentIndex, edtCenterX.Value, edtCenterY.Value)); + refreshAll(); + end + + function onUseCanvasCenter() + if ~hasCurrentImage() + return; + end + [canvas, ~] = currentCanvas(); + S.items(S.currentIndex).centerXY = [(size(canvas, 2) + 1) / 2, ... + (size(canvas, 1) + 1) / 2]; + S.items(S.currentIndex).centerSet = true; + addLog(sprintf('Set image %d crop center to rotated canvas center.', ... + S.currentIndex)); + refreshAll(); + end + + function onExportSettingChanged() + refreshSummary(); + end + + function onChooseOutputFolder() + folder = uigetdir(char(S.outputFolder), 'Select crop export folder'); + if isequal(folder, 0) + addLog('Export folder selection cancelled.'); + return; + end + S.outputFolder = string(folder); + txtOutputFolder.Value = char(S.outputFolder); + refreshSummary(); + end + + function onPreviewPointerDown(~, ~) + if ~hasCurrentImage() + return; + end + [canvas, ~] = currentCanvas(); + pt = previewAxes.CurrentPoint; + x = min(max(pt(1, 1), 1), size(canvas, 2)); + y = min(max(pt(1, 2), 1), size(canvas, 1)); + S.items(S.currentIndex).centerXY = [x, y]; + S.items(S.currentIndex).centerSet = true; + addLog(sprintf('Picked crop center for image %d: x=%.1f, y=%.1f.', ... + S.currentIndex, x, y)); + refreshAll(); + end + + function onExportCrops() + if isempty(S.items) + showError('No images loaded', 'Load images before exporting crops.'); + return; + end + if ~all([S.items.centerSet]) + showError('Crop centers missing', ... + 'Set or confirm the crop center for every loaded image before exporting.'); + return; + end + + opts = currentExportOptions(); + busyOpts = struct(); + busyOpts.title = 'Export crops'; + busyOpts.message = 'Writing cropped microscope images...'; + busyOpts.controls = [btnOpenFiles, btnClearImages, btnExport, ... + btnChooseOutput, btnPrevious, btnNext, btnUseCanvasCenter]; + try + payload = labkit.ui.app.runBusy(fig, ... + @() batch_crop.export.writeOutputs(S.items, opts), busyOpts); + catch ME + showError('Export failed', ME.message); + return; + end + + S.lastExport = payload; + statuses = string({payload.results.status}); + savedCount = sum(statuses == "saved"); + failedCount = sum(statuses == "failed"); + addLog(sprintf('Exported %d crop(s), %d failed. Manifest: %s', ... + savedCount, failedCount, char(payload.manifestPath))); + refreshSummary(); + if failedCount > 0 + showError('Some crops failed', ... + sprintf('%d image(s) failed. See the manifest for details.', failedCount)); + end + end + + function refreshAll() + refreshList(); + refreshControls(); + refreshPreview(); + refreshSummary(); + end + + function refreshList() + if isempty(S.items) + labkit.ui.view.setListItems(ui, 'images', {'No images loaded'}); + lbImages.Value = 'No images loaded'; + txtImageSource.Value = 'No images loaded'; + txtImageStatus.Value = 'Images: 0'; + return; + end + + items = batch_crop.view.listboxItems(S.items); + labkit.ui.view.setListItems(ui, 'images', items); + S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); + lbImages.Value = items{S.currentIndex}; + txtImageSource.Value = char(S.items(S.currentIndex).path); + txtImageStatus.Value = sprintf('Images: %d | confirmed centers: %d', ... + numel(S.items), countConfirmedCenters()); + end + + function refreshControls() + hasImage = hasCurrentImage(); + enabled = ternary(hasImage, 'on', 'off'); + btnClearImages.Enable = enabled; + btnPrevious.Enable = ternary(hasImage && S.currentIndex > 1, 'on', 'off'); + btnNext.Enable = ternary(hasImage && S.currentIndex < numel(S.items), 'on', 'off'); + edtRotation.Enable = enabled; + edtCenterX.Enable = enabled; + edtCenterY.Enable = enabled; + btnUseCanvasCenter.Enable = enabled; + + if hasImage + ensureCurrentCenter(); + item = S.items(S.currentIndex); + [canvas, ~] = currentCanvas(); + edtRotation.Value = item.angleDeg; + edtCenterX.Limits = [1, max(1, size(canvas, 2))]; + edtCenterY.Limits = [1, max(1, size(canvas, 1))]; + edtCenterX.Value = item.centerXY(1); + edtCenterY.Value = item.centerXY(2); + else + edtRotation.Value = 0; + edtCenterX.Limits = [1, Inf]; + edtCenterY.Limits = [1, Inf]; + edtCenterX.Value = 1; + edtCenterY.Value = 1; + end + + btnExport.Enable = ternary(hasImage && all([S.items.centerSet]), 'on', 'off'); + end + + function refreshPreview() + if ~hasCurrentImage() + resetPreviewAxes(); + cropSession.setBackground([]); + cropSession.setGraphics([]); + return; + end + + ensureCurrentCenter(); + [canvas, ~] = currentCanvas(); + hImage = labkit.ui.view.drawImage(ui, 'preview', canvas, ... + "title", "Rotated preview + fixed crop", ... + "axis", "crop"); + hold(previewAxes, 'on'); + item = S.items(S.currentIndex); + cropWidth = currentCropWidth(); + cropHeight = currentCropHeight(); + position = batch_crop.view.rectanglePosition(item.centerXY, cropWidth, cropHeight); + hRect = rectangle(previewAxes, 'Position', position, ... + 'EdgeColor', [1 0.84 0], ... + 'LineWidth', 1.5, ... + 'LineStyle', '-'); + hLineX = plot(previewAxes, ... + [item.centerXY(1) - 16, item.centerXY(1) + 16], ... + [item.centerXY(2), item.centerXY(2)], ... + 'Color', [0 0.85 1], ... + 'LineWidth', 1.25); + hLineY = plot(previewAxes, ... + [item.centerXY(1), item.centerXY(1)], ... + [item.centerXY(2) - 16, item.centerXY(2) + 16], ... + 'Color', [0 0.85 1], ... + 'LineWidth', 1.25); + hold(previewAxes, 'off'); + axis(previewAxes, 'image'); + cropSession.setBackground(hImage); + cropSession.setGraphics([hRect, hLineX, hLineY]); + cropSession.activate(); + end + + function refreshSummary() + if hasCurrentImage() + [canvas, ~] = currentCanvas(); + canvasSize = [size(canvas, 2), size(canvas, 1)]; + else + canvasSize = [0, 0]; + end + resultTable.Data = batch_crop.view.summaryTableData(S, S.currentIndex, ... + canvasSize, currentCropWidth(), currentCropHeight(), ddFormat.Value); + txtDetails.Value = batch_crop.view.detailLines(S, S.currentIndex, ... + currentCropWidth(), currentCropHeight(), ddFillMode.Value); + end + + function resetPreviewAxes() + labkit.ui.view.resetAxes(ui, 'preview', ... + 'Rotated preview + fixed crop', true, 'crop'); + end + + function opts = currentExportOptions() + opts = struct(); + opts.outputFolder = S.outputFolder; + opts.format = ddFormat.Value; + opts.cropWidth = currentCropWidth(); + opts.cropHeight = currentCropHeight(); + opts.fillMode = ddFillMode.Value; + end + + function width = currentCropWidth() + width = max(1, round(double(edtCropWidth.Value))); + end + + function height = currentCropHeight() + height = max(1, round(double(edtCropHeight.Value))); + end + + function [canvas, mask] = currentCanvas() + item = S.items(S.currentIndex); + fillValue = batch_crop.view.previewFillValue(item.image, ddFillMode.Value); + [canvas, mask] = batch_crop.ops.rotateCanvas(item.image, item.angleDeg, fillValue); + end + + function ensureCurrentCenter() + if ~hasCurrentImage() + return; + end + [canvas, ~] = currentCanvas(); + item = S.items(S.currentIndex); + if isempty(item.centerXY) || any(~isfinite(item.centerXY)) + item.centerXY = [(size(canvas, 2) + 1) / 2, (size(canvas, 1) + 1) / 2]; + end + item.centerXY(1) = min(max(item.centerXY(1), 1), size(canvas, 2)); + item.centerXY(2) = min(max(item.centerXY(2), 1), size(canvas, 1)); + S.items(S.currentIndex) = item; + end + + function tf = hasCurrentImage() + tf = ~isempty(S.items) && S.currentIndex >= 1 && S.currentIndex <= numel(S.items); + end + + function count = countConfirmedCenters() + if isempty(S.items) + count = 0; + else + count = sum([S.items.centerSet]); + end + end + + function items = readCropItems(paths) + items = batch_crop.state.readItems(paths); + end + + function addLog(message) + labkit.ui.view.appendLog(ui, 'appLog', message); + if debugLog.enabled + debugLog.append(message); + end + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end + + function value = ternary(condition, trueValue, falseValue) + if condition + value = trueValue; + else + value = falseValue; + end + end +end diff --git a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m index edb58c3..60adcc8 100644 --- a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m +++ b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m @@ -17,454 +17,11 @@ 'labkit_BatchImageCrop_app returns at most the app figure handle.'); end - S = struct(); - S.items = repmat(batch_crop.state.emptyItem(), 0, 1); - S.currentIndex = 0; - S.outputFolder = string(pwd); - S.lastExport = []; - - workbenchOpts = struct( ... - 'rightTitle', 'Crop Preview', ... - 'rightGridSize', [1 1], ... - 'rightRowHeight', {{'1x'}}); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [3 1], ... - {250, 260, 145}), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {240, '1x'}), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Microscope Batch Image Crop', ... - 'position', [80 60 1440 860], ... - 'leftWidth', 400, ... - 'options', workbenchOpts)); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - ui.previewAxes = uiaxes(ui.rightGrid); - ui.previewAxes.Layout.Row = 1; - title(ui.previewAxes, 'Rotated preview + fixed crop'); - labkit.ui.view.draw(ui.previewAxes, 'popout'); - imageRuntime = labkit.ui.tool.createRuntime(ui.previewAxes, ... - struct('figure', fig, 'onTrace', debugLog.trace)); - cropSession = imageRuntime.createSession(struct( ... - 'name', 'batchCropCenter', ... - 'onPointerDown', @onPreviewPointerDown, ... - 'installScrollWheel', false)); - - callbacks = struct( ... - 'onOpenFiles', @onOpenFiles, ... - 'onClearImages', @onClearImages, ... - 'onImageSelectionChanged', @onImageSelectionChanged, ... - 'onPreviousImage', @onPreviousImage, ... - 'onNextImage', @onNextImage, ... - 'onCropGeometryChanged', @onCropGeometryChanged, ... - 'onRotationChanged', @onRotationChanged, ... - 'onFillModeChanged', @onFillModeChanged, ... - 'onCenterChanged', @onCenterChanged, ... - 'onUseCanvasCenter', @onUseCanvasCenter, ... - 'onExportSettingChanged', @onExportSettingChanged, ... - 'onChooseOutputFolder', @onChooseOutputFolder, ... - 'onExportCrops', @onExportCrops); - controls = batch_crop.ui.createControls(layFA, laySR, layLog, ... - S.outputFolder, callbacks); - btnOpenFiles = controls.btnOpenFiles; - btnClearImages = controls.btnClearImages; - txtImageSource = controls.txtImageSource; - lbImages = controls.lbImages; - btnPrevious = controls.btnPrevious; - btnNext = controls.btnNext; - txtImageStatus = controls.txtImageStatus; - edtCropWidth = controls.edtCropWidth; - edtCropHeight = controls.edtCropHeight; - edtRotation = controls.edtRotation; - ddFillMode = controls.ddFillMode; - edtCenterX = controls.edtCenterX; - edtCenterY = controls.edtCenterY; - btnUseCanvasCenter = controls.btnUseCanvasCenter; - ddFormat = controls.ddFormat; - txtOutputFolder = controls.txtOutputFolder; - btnChooseOutput = controls.btnChooseOutput; - btnExport = controls.btnExport; - resultTable = controls.resultTable; - txtDetails = controls.txtDetails; - txtLog = controls.txtLog; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('Batch image crop debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - resetPreviewAxes(); - refreshAll(); - + fig = batch_crop.run(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenFiles(~, ~) - [files, folder] = uigetfile(batch_crop.io.imageDialogFilter(), ... - 'Select microscope images', pwd, 'MultiSelect', 'on'); - if isequal(files, 0) - addLog('Image file selection cancelled.'); - return; - end - - try - paths = batch_crop.io.selectedImagePaths(files, folder); - items = readCropItems(paths); - catch ME - showError('Could not load images', ME.message); - return; - end - - S.items = items; - S.currentIndex = 1; - S.lastExport = []; - addLog(sprintf('Loaded %d image(s).', numel(S.items))); - refreshAll(); - end - - function onClearImages(~, ~) - S.items = repmat(batch_crop.state.emptyItem(), 0, 1); - S.currentIndex = 0; - S.lastExport = []; - addLog('Cleared loaded images.'); - refreshAll(); - end - - function onImageSelectionChanged(~, ~) - if isempty(S.items) - return; - end - items = batch_crop.view.listboxItems(S.items); - idx = find(strcmp(items, lbImages.Value), 1); - if isempty(idx) - return; - end - S.currentIndex = idx; - refreshAll(); - end - - function onPreviousImage(~, ~) - if isempty(S.items) - return; - end - S.currentIndex = max(1, S.currentIndex - 1); - refreshAll(); - end - - function onNextImage(~, ~) - if isempty(S.items) - return; - end - S.currentIndex = min(numel(S.items), S.currentIndex + 1); - refreshAll(); - end - - function onCropGeometryChanged(~, ~) - edtCropWidth.Value = round(max(1, edtCropWidth.Value)); - edtCropHeight.Value = round(max(1, edtCropHeight.Value)); - refreshPreview(); - refreshSummary(); - end - - function onRotationChanged(~, ~) - if ~hasCurrentImage() - return; - end - S.items(S.currentIndex).angleDeg = edtRotation.Value; - ensureCurrentCenter(); - addLog(sprintf('Updated rotation for image %d: %.3g deg.', ... - S.currentIndex, S.items(S.currentIndex).angleDeg)); - refreshAll(); - end - - function onFillModeChanged(~, ~) - refreshPreview(); - refreshSummary(); - end - - function onCenterChanged(~, ~) - if ~hasCurrentImage() - return; - end - S.items(S.currentIndex).centerXY = [edtCenterX.Value, edtCenterY.Value]; - S.items(S.currentIndex).centerSet = true; - addLog(sprintf('Set crop center for image %d: x=%.1f, y=%.1f.', ... - S.currentIndex, edtCenterX.Value, edtCenterY.Value)); - refreshAll(); - end - - function onUseCanvasCenter(~, ~) - if ~hasCurrentImage() - return; - end - [canvas, ~] = currentCanvas(); - S.items(S.currentIndex).centerXY = [(size(canvas, 2) + 1) / 2, ... - (size(canvas, 1) + 1) / 2]; - S.items(S.currentIndex).centerSet = true; - addLog(sprintf('Set image %d crop center to rotated canvas center.', ... - S.currentIndex)); - refreshAll(); - end - - function onExportSettingChanged(~, ~) - refreshSummary(); - end - - function onChooseOutputFolder(~, ~) - folder = uigetdir(char(S.outputFolder), 'Select crop export folder'); - if isequal(folder, 0) - addLog('Export folder selection cancelled.'); - return; - end - S.outputFolder = string(folder); - txtOutputFolder.Value = char(S.outputFolder); - refreshSummary(); - end - - function onPreviewPointerDown(~, ~) - if ~hasCurrentImage() - return; - end - [canvas, ~] = currentCanvas(); - pt = ui.previewAxes.CurrentPoint; - x = min(max(pt(1, 1), 1), size(canvas, 2)); - y = min(max(pt(1, 2), 1), size(canvas, 1)); - S.items(S.currentIndex).centerXY = [x, y]; - S.items(S.currentIndex).centerSet = true; - addLog(sprintf('Picked crop center for image %d: x=%.1f, y=%.1f.', ... - S.currentIndex, x, y)); - refreshAll(); - end - - function onExportCrops(~, ~) - if isempty(S.items) - showError('No images loaded', 'Load images before exporting crops.'); - return; - end - if ~all([S.items.centerSet]) - showError('Crop centers missing', ... - 'Set or confirm the crop center for every loaded image before exporting.'); - return; - end - - opts = currentExportOptions(); - busyOpts = struct(); - busyOpts.title = 'Export crops'; - busyOpts.message = 'Writing cropped microscope images...'; - busyOpts.controls = [btnOpenFiles, btnClearImages, btnExport, ... - btnChooseOutput, btnPrevious, btnNext, btnUseCanvasCenter]; - try - payload = labkit.ui.app.runBusy(fig, ... - @() batch_crop.export.writeOutputs(S.items, opts), busyOpts); - catch ME - showError('Export failed', ME.message); - return; - end - - S.lastExport = payload; - statuses = string({payload.results.status}); - savedCount = sum(statuses == "saved"); - failedCount = sum(statuses == "failed"); - addLog(sprintf('Exported %d crop(s), %d failed. Manifest: %s', ... - savedCount, failedCount, char(payload.manifestPath))); - refreshSummary(); - if failedCount > 0 - showError('Some crops failed', ... - sprintf('%d image(s) failed. See the manifest for details.', failedCount)); - end - end - - function refreshAll() - refreshList(); - refreshControls(); - refreshPreview(); - refreshSummary(); - end - - function refreshList() - if isempty(S.items) - lbImages.Items = {'No images loaded'}; - lbImages.Value = 'No images loaded'; - txtImageSource.Value = 'No images loaded'; - txtImageStatus.Value = 'Images: 0'; - return; - end - - items = batch_crop.view.listboxItems(S.items); - lbImages.Items = items; - S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); - lbImages.Value = items{S.currentIndex}; - txtImageSource.Value = char(S.items(S.currentIndex).path); - txtImageStatus.Value = sprintf('Images: %d | confirmed centers: %d', ... - numel(S.items), countConfirmedCenters()); - end - - function refreshControls() - hasImage = hasCurrentImage(); - enabled = ternary(hasImage, 'on', 'off'); - btnClearImages.Enable = enabled; - btnPrevious.Enable = ternary(hasImage && S.currentIndex > 1, 'on', 'off'); - btnNext.Enable = ternary(hasImage && S.currentIndex < numel(S.items), 'on', 'off'); - edtRotation.Enable = enabled; - edtCenterX.Enable = enabled; - edtCenterY.Enable = enabled; - btnUseCanvasCenter.Enable = enabled; - - if hasImage - ensureCurrentCenter(); - item = S.items(S.currentIndex); - [canvas, ~] = currentCanvas(); - edtRotation.Value = item.angleDeg; - edtCenterX.Limits = [1, max(1, size(canvas, 2))]; - edtCenterY.Limits = [1, max(1, size(canvas, 1))]; - edtCenterX.Value = item.centerXY(1); - edtCenterY.Value = item.centerXY(2); - else - edtRotation.Value = 0; - edtCenterX.Limits = [1, Inf]; - edtCenterY.Limits = [1, Inf]; - edtCenterX.Value = 1; - edtCenterY.Value = 1; - end - - btnExport.Enable = ternary(hasImage && all([S.items.centerSet]), 'on', 'off'); - end - - function refreshPreview() - if ~hasCurrentImage() - resetPreviewAxes(); - cropSession.setBackground([]); - cropSession.setGraphics([]); - return; - end - - ensureCurrentCenter(); - [canvas, ~] = currentCanvas(); - hImage = labkit.ui.view.draw(ui.previewAxes, 'image', canvas, ... - 'Rotated preview + fixed crop'); - hold(ui.previewAxes, 'on'); - item = S.items(S.currentIndex); - cropWidth = currentCropWidth(); - cropHeight = currentCropHeight(); - position = batch_crop.view.rectanglePosition(item.centerXY, cropWidth, cropHeight); - hRect = rectangle(ui.previewAxes, 'Position', position, ... - 'EdgeColor', [1 0.84 0], ... - 'LineWidth', 1.5, ... - 'LineStyle', '-'); - hLineX = plot(ui.previewAxes, ... - [item.centerXY(1) - 16, item.centerXY(1) + 16], ... - [item.centerXY(2), item.centerXY(2)], ... - 'Color', [0 0.85 1], ... - 'LineWidth', 1.25); - hLineY = plot(ui.previewAxes, ... - [item.centerXY(1), item.centerXY(1)], ... - [item.centerXY(2) - 16, item.centerXY(2) + 16], ... - 'Color', [0 0.85 1], ... - 'LineWidth', 1.25); - hold(ui.previewAxes, 'off'); - axis(ui.previewAxes, 'image'); - cropSession.setBackground(hImage); - cropSession.setGraphics([hRect, hLineX, hLineY]); - cropSession.activate(); - end - - function refreshSummary() - if hasCurrentImage() - [canvas, ~] = currentCanvas(); - canvasSize = [size(canvas, 2), size(canvas, 1)]; - else - canvasSize = [0, 0]; - end - resultTable.Data = batch_crop.view.summaryTableData(S, S.currentIndex, ... - canvasSize, currentCropWidth(), currentCropHeight(), ddFormat.Value); - txtDetails.Value = batch_crop.view.detailLines(S, S.currentIndex, ... - currentCropWidth(), currentCropHeight(), ddFillMode.Value); - end - - function resetPreviewAxes() - labkit.ui.view.draw(ui.previewAxes, 'reset', ... - 'Rotated preview + fixed crop', true); - end - - function opts = currentExportOptions() - opts = struct(); - opts.outputFolder = S.outputFolder; - opts.format = ddFormat.Value; - opts.cropWidth = currentCropWidth(); - opts.cropHeight = currentCropHeight(); - opts.fillMode = ddFillMode.Value; - end - - function width = currentCropWidth() - width = max(1, round(double(edtCropWidth.Value))); - end - - function height = currentCropHeight() - height = max(1, round(double(edtCropHeight.Value))); - end - - function [canvas, mask] = currentCanvas() - item = S.items(S.currentIndex); - fillValue = batch_crop.view.previewFillValue(item.image, ddFillMode.Value); - [canvas, mask] = batch_crop.ops.rotateCanvas(item.image, item.angleDeg, fillValue); - end - - function ensureCurrentCenter() - if ~hasCurrentImage() - return; - end - [canvas, ~] = currentCanvas(); - item = S.items(S.currentIndex); - if isempty(item.centerXY) || any(~isfinite(item.centerXY)) - item.centerXY = [(size(canvas, 2) + 1) / 2, (size(canvas, 1) + 1) / 2]; - end - item.centerXY(1) = min(max(item.centerXY(1), 1), size(canvas, 2)); - item.centerXY(2) = min(max(item.centerXY(2), 1), size(canvas, 1)); - S.items(S.currentIndex) = item; - end - - function tf = hasCurrentImage() - tf = ~isempty(S.items) && S.currentIndex >= 1 && S.currentIndex <= numel(S.items); - end - - function count = countConfirmedCenters() - if isempty(S.items) - count = 0; - else - count = sum([S.items.centerSet]); - end - end - - function items = readCropItems(paths) - items = batch_crop.state.readItems(paths); - end - - function addLog(message) - labkit.ui.view.update(txtLog, 'appendLog', message); - if debugLog.enabled - debugLog.append(message); - end - end - - function showError(titleText, message) - addLog(sprintf('%s: %s', titleText, message)); - uialert(fig, message, titleText); - end - - function value = ternary(condition, trueValue, falseValue) - if condition - value = trueValue; - else - value = falseValue; - end - end end diff --git a/apps/image_measurement/curvature/+curvature/+ops/computeCurvatureFit.m b/apps/image_measurement/curvature/+curvature/+ops/computeCurvatureFit.m index 73969de..1967a4c 100644 --- a/apps/image_measurement/curvature/+curvature/+ops/computeCurvatureFit.m +++ b/apps/image_measurement/curvature/+curvature/+ops/computeCurvatureFit.m @@ -8,9 +8,9 @@ % labkit_CurvatureMeasurement_app callbacks and package tests. % % Inputs/outputs: -% Pixel anchor vectors, a labkit.ui scale-bar calibration struct, and -% optional displayed fit-path vectors. Returns the same fit-result struct -% previously built inside the app file. +% Pixel anchor vectors, a GUI-free scale calibration struct, and optional +% displayed fit-path vectors. Returns the same fit-result struct previously +% built inside the app file. % % Side effects: % None. This helper performs GUI-free numeric fitting only. @@ -26,7 +26,9 @@ end if nargin < 3 || isempty(calibration) - calibration = labkit.ui.tool.scaleBarCalibration(); + calibration = curvature.ops.normalizeScaleCalibration(); + else + calibration = curvature.ops.normalizeScaleCalibration(calibration); end if nargin < 4 || isempty(doDensify) diff --git a/apps/image_measurement/curvature/+curvature/+ops/computeCurveLength.m b/apps/image_measurement/curvature/+curvature/+ops/computeCurveLength.m index a4cdb1e..15f659a 100644 --- a/apps/image_measurement/curvature/+curvature/+ops/computeCurveLength.m +++ b/apps/image_measurement/curvature/+curvature/+ops/computeCurveLength.m @@ -9,8 +9,8 @@ % curvature fit helpers. % % Inputs/outputs: -% Pixel vectors plus a labkit.ui scale-bar calibration struct. Returns the -% same length-result struct previously built inside the app file. +% Pixel vectors plus a GUI-free scale calibration struct. Returns the same +% length-result struct previously built inside the app file. % % Side effects: % None. This helper performs GUI-free numeric length measurement only. @@ -26,7 +26,9 @@ end if nargin < 3 || isempty(calibration) - calibration = labkit.ui.tool.scaleBarCalibration(); + calibration = curvature.ops.normalizeScaleCalibration(); + else + calibration = curvature.ops.normalizeScaleCalibration(calibration); end lengthPx = sum(hypot(diff(xPix), diff(yPix))); diff --git a/apps/image_measurement/curvature/+curvature/+ops/normalizeScaleCalibration.m b/apps/image_measurement/curvature/+curvature/+ops/normalizeScaleCalibration.m new file mode 100644 index 0000000..b3854b3 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+ops/normalizeScaleCalibration.m @@ -0,0 +1,91 @@ +% App-owned curvature calculation helper. Expected caller: curvature ops and +% package tests. Input is a partial calibration struct or raw scale fields. +% Output is a GUI-free calibration struct. Side effects: none. +function calibration = normalizeScaleCalibration(referencePixels, referenceLength, scaleUnit, opts) +%NORMALIZESCALECALIBRATION Normalize scale calibration for curvature ops. + + if nargin == 1 && isstruct(referencePixels) + existing = referencePixels; + referencePixels = fieldValue(existing, 'referencePixels', NaN); + referenceLength = fieldValue(existing, 'referenceLength', 0); + scaleUnit = fieldValue(existing, 'unit', ''); + opts = struct('referenceLine', fieldValue(existing, ... + 'referenceLine', zeros(0, 2))); + else + if nargin < 1 + referencePixels = NaN; + end + if nargin < 2 + referenceLength = 0; + end + if nargin < 3 + scaleUnit = ''; + end + if nargin < 4 + opts = struct(); + end + end + + referenceLine = normalizeReferenceLine(fieldValue(opts, ... + 'referenceLine', zeros(0, 2))); + referencePixels = positiveOrNaN(referencePixels); + if ~isfinite(referencePixels) && size(referenceLine, 1) == 2 + referencePixels = hypot(referenceLine(2, 1) - referenceLine(1, 1), ... + referenceLine(2, 2) - referenceLine(1, 2)); + referencePixels = positiveOrNaN(referencePixels); + end + referenceLength = nonnegativeScalar(referenceLength); + scaleUnit = normalizeUnit(scaleUnit); + pixelsPerUnit = 0; + if isfinite(referencePixels) && referencePixels > 0 && referenceLength > 0 + pixelsPerUnit = referencePixels / referenceLength; + end + + calibration = struct( ... + 'referencePixels', referencePixels, ... + 'referenceLength', referenceLength, ... + 'unit', scaleUnit, ... + 'pixelsPerUnit', pixelsPerUnit, ... + 'isCalibrated', pixelsPerUnit > 0, ... + 'referenceLine', referenceLine); +end + +function value = fieldValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end + +function referenceLine = normalizeReferenceLine(referenceLine) + if isempty(referenceLine) + referenceLine = zeros(0, 2); + return; + end + referenceLine = double(referenceLine); + if size(referenceLine, 2) ~= 2 + referenceLine = zeros(0, 2); + end +end + +function value = positiveOrNaN(value) + if isempty(value) || ~isnumeric(value) || ~isscalar(value) || ... + ~isfinite(value) || value <= 0 + value = NaN; + end +end + +function value = nonnegativeScalar(value) + if isempty(value) || ~isnumeric(value) || ~isscalar(value) || ... + ~isfinite(value) || value < 0 + value = 0; + end +end + +function unit = normalizeUnit(unit) + allowed = {'m', 'cm', 'mm', 'um', 'nm'}; + unit = char(string(unit)); + if ~any(strcmp(unit, allowed)) + unit = allowed{1}; + end +end diff --git a/apps/image_measurement/curvature/+curvature/+ops/scaleOptionsFromStruct.m b/apps/image_measurement/curvature/+curvature/+ops/scaleOptionsFromStruct.m index 21a9f51..5998c18 100644 --- a/apps/image_measurement/curvature/+curvature/+ops/scaleOptionsFromStruct.m +++ b/apps/image_measurement/curvature/+curvature/+ops/scaleOptionsFromStruct.m @@ -8,8 +8,8 @@ % labkit_CurvatureMeasurement_app package tests. % % Inputs/outputs: -% Option struct with current and legacy scale fields. Returns a -% labkit.ui scale-bar calibration struct. +% Option struct with current and legacy scale fields. Returns a GUI-free +% calibration struct for curvature calculations. % % Side effects: % None. @@ -38,7 +38,8 @@ referenceLength = 1; scaleUnit = 'mm'; end - calibration = labkit.ui.tool.scaleBarCalibration(referencePx, referenceLength, scaleUnit); + calibration = curvature.ops.normalizeScaleCalibration(referencePx, ... + referenceLength, scaleUnit); end function value = positiveOrNaN(value) diff --git a/apps/image_measurement/curvature/+curvature/+ui/appShellOptions.m b/apps/image_measurement/curvature/+curvature/+ui/appShellOptions.m deleted file mode 100644 index 74bb1a6..0000000 --- a/apps/image_measurement/curvature/+curvature/+ui/appShellOptions.m +++ /dev/null @@ -1,18 +0,0 @@ -% App-owned curvature shell options helper. Expected caller: -% labkit_CurvatureMeasurement_app. Output is the createShell options struct. -% Encodes only layout constants and has no side effects. -function opts = appShellOptions() -%APPSHELLOPTIONS Return createShell options for the curvature app. - - opts = struct( ... - 'rightTitle', 'Measurement Preview', ... - 'rightGridSize', [1 1], ... - 'rightRowHeight', {{'1x'}}); - opts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [5 1], ... - {140, 105, 355, 225, 160}, ... - struct('resizeOptions', struct('minTopHeight', 140, 'minBottomHeight', 90))), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {170, '1x'}), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; -end diff --git a/apps/image_measurement/curvature/+curvature/+ui/buildSpec.m b/apps/image_measurement/curvature/+curvature/+ui/buildSpec.m new file mode 100644 index 0000000..525fb28 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+ui/buildSpec.m @@ -0,0 +1,99 @@ +% Expected caller: curvature.run. Input is a callback struct whose fields are +% app-owned callback handles. Output is a data-only UI 2.0 workbench spec for +% the Curvature Measurement app. +function spec = buildSpec(callbacks) + + spec = labkit.ui.spec.app("curvatureApp", ... + "Image Curvature Measurement", ... + "position", [90 70 1420 860], ... + "leftWidth", 390, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("imageSection", "Image", { ... + labkit.ui.spec.action("openImage", "Open image", ... + callbackValue(callbacks, "onOpenImage")), ... + labkit.ui.spec.field("imagePath", "Image", ... + "kind", "readonly", ... + "value", "No image loaded"), ... + labkit.ui.spec.field("pointCount", "Curve points", ... + "kind", "readonly", ... + "value", "Points: 0")}, ... + "height", 150), ... + labkit.ui.spec.section("curveEditing", "Curve Editing", { ... + labkit.ui.spec.action("startCurveEdit", ... + "Start curve edit", callbackValue(callbacks, ... + "onStartCurveEdit")), ... + labkit.ui.spec.actionGroup("curveEditActions", { ... + labkit.ui.spec.action("undoCurvePoint", ... + "Undo last point", callbackValue(callbacks, ... + "onUndoCurvePoint"), "enabled", false), ... + labkit.ui.spec.action("clearCurve", ... + "Clear curve", callbackValue(callbacks, ... + "onClearCurve"), "enabled", false)})}, ... + "height", 100), ... + labkit.ui.spec.section("scaleBarSection", "Scale Bar", {}, ... + "height", 260), ... + labkit.ui.spec.section("fitExport", "Fit + Export", { ... + labkit.ui.spec.field("densify", ... + "Densify before circle fit", ... + "kind", "checkbox", "value", true), ... + labkit.ui.spec.field("densePointCount", ... + "Dense point count:", ... + "kind", "spinner", "value", 300, ... + "limits", [3 Inf], "step", 25), ... + labkit.ui.spec.field("showDensePoints", ... + "Show dense fit points", ... + "kind", "checkbox", "value", true, ... + "onChange", callbackValue(callbacks, ... + "onShowDenseChanged")), ... + labkit.ui.spec.action("fitCurvature", ... + "Fit circle + curvature", callbackValue(callbacks, ... + "onFitCurvature")), ... + labkit.ui.spec.action("measureCurveLength", ... + "Measure curve length", callbackValue(callbacks, ... + "onMeasureCurveLength")), ... + labkit.ui.spec.action("exportCsv", ... + "Export result CSV", callbackValue(callbacks, ... + "onExportCSV")), ... + labkit.ui.spec.action("exportOverlay", ... + "Export overlay PNG", callbackValue(callbacks, ... + "onExportOverlay"))}, ... + "height", 260), ... + labkit.ui.spec.section("workflowNotes", ... + "Workflow Notes", { ... + labkit.ui.spec.statusPanel("workflowNotesText", ... + "Workflow Notes", "value", { ... + '1. Open an image and start curve editing.', ... + '2. Double-click blank image space to add/insert points; drag points to move; double-click a point to delete it.', ... + '3. Calibrate with measured or typed reference pixels, a real reference length, and a unit.', ... + '4. Place the final scale bar, then fit curvature or measure curve length.'})}, ... + "height", 170)}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("resultsSection", "Results", { ... + labkit.ui.spec.resultTable("resultTable", ... + "Curvature Results", ... + "columns", {'Metric', 'Value'}, ... + "data", curvature.view.initialResultTable())}, ... + "height", 220), ... + labkit.ui.spec.section("detailsSection", "Details", { ... + labkit.ui.spec.statusPanel("detailsText", "Details", ... + "value", {'No curvature result yet.'})})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log", ... + "value", {'Ready.'})})})}, ... + "workspace", labkit.ui.spec.workspace("curvaturePreview", ... + "Image Preview", { ... + labkit.ui.spec.previewArea("imageAxes", "Image Preview", ... + "layout", "single", ... + "axisIds", {'image'}, ... + "axisTitles", {'Image + Circle Fit'})})); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/image_measurement/curvature/+curvature/+ui/createControls.m b/apps/image_measurement/curvature/+curvature/+ui/createControls.m deleted file mode 100644 index 3171793..0000000 --- a/apps/image_measurement/curvature/+curvature/+ui/createControls.m +++ /dev/null @@ -1,125 +0,0 @@ -% App-owned curvature control construction helper. Expected caller: -% labkit_CurvatureMeasurement_app. Inputs are shell grids, an image tool runtime, -% and callbacks. Output is a struct of app UI handles. Side effects are limited -% to creating UI components under the supplied parents. -function controls = createControls(layFA, laySR, layLog, imageRuntime, callbacks) -%CREATECONTROLS Create the curvature app control panels. - - imagePanel = labkit.ui.view.section(layFA, 'Image', 1, [3 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - imageGrid = imagePanel.grid; - - btnOpenImage = uibutton(imageGrid, 'Text', 'Open image', ... - 'ButtonPushedFcn', callbacks.onOpenImage); - btnOpenImage.Layout.Row = 1; - btnOpenImage.Layout.Column = [1 2]; - - controls.txtImage = labkit.ui.view.form(imageGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No image loaded')); - controls.txtImage.Layout.Row = 2; - controls.txtImage.Layout.Column = [1 2]; - - controls.txtPointCount = labkit.ui.view.form(imageGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'Points: 0')); - controls.txtPointCount.Layout.Row = 3; - controls.txtPointCount.Layout.Column = [1 2]; - - editPanel = labkit.ui.view.section(layFA, 'Curve Editing', 2, [2 2], ... - struct('rowHeight', {{'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - editGrid = editPanel.grid; - - controls.btnStartCurve = uibutton(editGrid, 'Text', 'Start curve edit', ... - 'ButtonPushedFcn', callbacks.onStartCurveEdit); - controls.btnStartCurve.Layout.Row = 1; - controls.btnStartCurve.Layout.Column = [1 2]; - - controls.btnUndoPoint = uibutton(editGrid, 'Text', 'Undo last point', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.onUndoCurvePoint); - controls.btnUndoPoint.Layout.Row = 2; - controls.btnUndoPoint.Layout.Column = 1; - controls.btnClearCurve = uibutton(editGrid, 'Text', 'Clear curve', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.onClearCurve); - controls.btnClearCurve.Layout.Row = 2; - controls.btnClearCurve.Layout.Column = 2; - - controls.scaleTool = labkit.ui.tool.scaleBar(layFA, 3, imageRuntime, ... - struct('onBeforeReferenceEdit', callbacks.onBeforeReferenceEdit, ... - 'onReferenceEditChanged', callbacks.onReferenceEditChanged, ... - 'onCalibrationChanged', callbacks.onCalibrationSettingsChanged, ... - 'onScaleBarChanged', callbacks.onScaleBarSettingsChanged, ... - 'onScaleBarPlaced', callbacks.onScaleBarPlaced, ... - 'onError', callbacks.onScaleToolError, ... - 'onTrace', callbacks.onTrace)); - - fitPanel = labkit.ui.view.section(layFA, 'Fit + Export', 4, [7 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - fitGrid = fitPanel.grid; - - controls.chkDensify = uicheckbox(fitGrid, ... - 'Text', 'Densify before circle fit', 'Value', true); - controls.chkDensify.Layout.Row = 1; - controls.chkDensify.Layout.Column = [1 2]; - - [lblDenseN, controls.edtDenseN] = labkit.ui.view.form(fitGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Dense point count:', ... - 'value', 300, ... - 'limits', [3 Inf], ... - 'step', 25)); - lblDenseN.Layout.Row = 2; - lblDenseN.Layout.Column = 1; - controls.edtDenseN.Layout.Row = 2; - controls.edtDenseN.Layout.Column = 2; - - controls.chkShowDense = uicheckbox(fitGrid, ... - 'Text', 'Show dense fit points', ... - 'Value', true, ... - 'ValueChangedFcn', callbacks.onShowDenseChanged); - controls.chkShowDense.Layout.Row = 3; - controls.chkShowDense.Layout.Column = [1 2]; - - controls.btnFit = uibutton(fitGrid, 'Text', 'Fit circle + curvature', ... - 'ButtonPushedFcn', callbacks.onFitCurvature); - controls.btnFit.Layout.Row = 4; - controls.btnFit.Layout.Column = [1 2]; - - controls.btnMeasureLength = uibutton(fitGrid, ... - 'Text', 'Measure curve length', ... - 'ButtonPushedFcn', callbacks.onMeasureCurveLength); - controls.btnMeasureLength.Layout.Row = 5; - controls.btnMeasureLength.Layout.Column = [1 2]; - - controls.btnExportCSV = uibutton(fitGrid, 'Text', 'Export result CSV', ... - 'ButtonPushedFcn', callbacks.onExportCSV); - controls.btnExportCSV.Layout.Row = 6; - controls.btnExportCSV.Layout.Column = [1 2]; - controls.btnExportOverlay = uibutton(fitGrid, 'Text', 'Export overlay PNG', ... - 'ButtonPushedFcn', callbacks.onExportOverlay); - controls.btnExportOverlay.Layout.Row = 7; - controls.btnExportOverlay.Layout.Column = [1 2]; - - labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 5, { ... - '1. Open an image and start curve editing.', ... - '2. Double-click blank image space to add/insert points; drag points to move; double-click a point to delete it.', ... - '3. Calibrate with measured or typed reference pixels, a real reference length, and a unit.', ... - '4. Place the final scale bar, then fit curvature or measure curve length.'}); - - controls.resultTable = uitable(laySR, ... - 'ColumnName', {'Metric', 'Value'}, ... - 'Data', curvature.view.initialResultTable()); - controls.resultTable.Layout.Row = 1; - - controls.txtDetails = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(controls.txtDetails, laySR, 2); - controls.txtDetails.Value = {'No curvature result yet.'}; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - controls.txtLog = logUi.textArea; -end diff --git a/apps/image_measurement/curvature/+curvature/+ui/mapControlHandles.m b/apps/image_measurement/curvature/+curvature/+ui/mapControlHandles.m new file mode 100644 index 0000000..35bf49e --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+ui/mapControlHandles.m @@ -0,0 +1,24 @@ +% Expected caller: curvature.run after labkit.ui.app.create. Inputs are the +% UI 2.0 registry and the scale-bar tool. Output is the app's control handle +% struct used by the existing runner logic. Side effects: none. +function controls = mapControlHandles(ui, scaleTool) +%MAPCONTROLHANDLES Map UI 2.0 adapters to curvature control handles. + + controls = struct(); + controls.txtImage = ui.controls.imagePath.valueHandle; + controls.txtPointCount = ui.controls.pointCount.valueHandle; + controls.btnStartCurve = ui.controls.startCurveEdit.button; + controls.btnUndoPoint = ui.controls.undoCurvePoint.button; + controls.btnClearCurve = ui.controls.clearCurve.button; + controls.scaleTool = scaleTool; + controls.chkDensify = ui.controls.densify.valueHandle; + controls.edtDenseN = ui.controls.densePointCount.valueHandle; + controls.chkShowDense = ui.controls.showDensePoints.valueHandle; + controls.btnFit = ui.controls.fitCurvature.button; + controls.btnMeasureLength = ui.controls.measureCurveLength.button; + controls.btnExportCSV = ui.controls.exportCsv.button; + controls.btnExportOverlay = ui.controls.exportOverlay.button; + controls.resultTable = ui.controls.resultTable.table; + controls.txtDetails = ui.controls.detailsText.textArea; + controls.txtLog = ui.controls.appLog.textArea; +end diff --git a/apps/image_measurement/curvature/+curvature/run.m b/apps/image_measurement/curvature/+curvature/run.m new file mode 100644 index 0000000..60f5620 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/run.m @@ -0,0 +1,475 @@ +% Expected caller: labkit_CurvatureMeasurement_app. Input is the debug context +% prepared by the public launcher. Output is the app figure. Side effects are +% GUI creation, user-driven file I/O, exports, and debug trace attachment. +function fig = run(debugLog) +%RUN Build and run the image curvature measurement app body. + + S = struct(); + S.imagePath = ""; + S.image = []; + S.xPix = []; + S.yPix = []; + S.curveEditor = []; + S.curveEditActive = false; + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); + + callbacks = struct( ... + 'onOpenImage', @onOpenImage, ... + 'onStartCurveEdit', @onStartCurveEdit, ... + 'onUndoCurvePoint', @onUndoCurvePoint, ... + 'onClearCurve', @onClearCurve, ... + 'onShowDenseChanged', @refreshImageOverlayCallback, ... + 'onFitCurvature', @onFitCurvature, ... + 'onMeasureCurveLength', @onMeasureCurveLength, ... + 'onExportCSV', @onExportCSV, ... + 'onExportOverlay', @onExportOverlay); + spec = curvature.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + fig = ui.fig; + ui.topAxes = ui.controls.imageAxes.primaryAxes; + imageRuntime = labkit.ui.tool.createRuntime(ui.topAxes, ... + struct('figure', fig, ... + 'defaultScrollFcn', @onPreviewScroll, ... + 'onTrace', debugLog.trace)); + + scaleTool = labkit.ui.tool.scaleBar(ui.sections.scaleBarSection.grid, ... + 1, imageRuntime, struct( ... + 'onBeforeReferenceEdit', @onBeforeReferenceEdit, ... + 'onReferenceEditChanged', @onReferenceEditChanged, ... + 'onCalibrationChanged', @onCalibrationSettingsChanged, ... + 'onScaleBarChanged', @onScaleBarSettingsChanged, ... + 'onScaleBarPlaced', @onScaleBarPlaced, ... + 'onError', @onScaleToolError, ... + 'onTrace', debugLog.trace)); + controls = curvature.ui.mapControlHandles(ui, scaleTool); + txtImage = controls.txtImage; + txtPointCount = controls.txtPointCount; + btnStartCurve = controls.btnStartCurve; + btnUndoPoint = controls.btnUndoPoint; + btnClearCurve = controls.btnClearCurve; + scaleTool = controls.scaleTool; + chkDensify = controls.chkDensify; + edtDenseN = controls.edtDenseN; + chkShowDense = controls.chkShowDense; + btnFit = controls.btnFit; + btnMeasureLength = controls.btnMeasureLength; + btnExportCSV = controls.btnExportCSV; + btnExportOverlay = controls.btnExportOverlay; + resultTable = controls.resultTable; + txtDetails = controls.txtDetails; + txtLog = controls.txtLog; + + if debugLog.enabled + debugLog.trace('Curvature measurement debug trace enabled.'); + end + + resetAxes(); + refreshScaleReadout(); + + function onOpenImage(~, ~) + [fn, fp] = uigetfile( ... + {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', 'Image files'}, ... + 'Select image'); + if isequal(fn, 0) + addLog('Image selection cancelled.'); + return; + end + + filepath = string(fullfile(fp, fn)); + try + img = imread(filepath); + catch ME + showError('Could not read image', ME.message); + return; + end + + S.imagePath = filepath; + S.image = img; + S.xPix = []; + S.yPix = []; + scaleTool.resetForNewImage(size(S.image)); + S.curveEditActive = false; + if ~isempty(S.curveEditor) + S.curveEditor.delete(); + end + S.curveEditor = []; + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); + txtImage.Value = char(filepath); + addLog(sprintf('Loaded image: %s', filepath)); + refreshAll(); + end + + function onStartCurveEdit(~, ~) + if isempty(S.image) + showError('No image loaded', 'Open an image before editing curve points.'); + return; + end + + if S.curveEditActive + S.curveEditActive = false; + if ~isempty(S.curveEditor) + S.curveEditor.setActive(false); + end + addLog('Finished curve edit.'); + refreshAll(); + return; + end + + scaleTool.finishReferenceEdit(false); + S.curveEditActive = true; + ensureCurveEditor(); + S.curveEditor.start([S.xPix(:), S.yPix(:)]); + S.fit = curvature.state.emptyFitResult(); + addLog('Started curve edit. Double-click blank image space to add/insert points; drag points to move; double-click a point to delete it.'); + refreshAll(); + end + + function onCurveEditorChanged(points, reason) + S.xPix = points(:, 1); + S.yPix = points(:, 2); + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); + refreshSummary(); + if any(strcmp(reason, {'add point', 'delete point', 'move point'})) + addLog(sprintf('Curve edit updated: %d point(s).', numel(S.xPix))); + end + end + + function onUndoCurvePoint(~, ~) + if ~isempty(S.curveEditor) + S.curveEditor.undoLast(); + end + end + + function onClearCurve(~, ~) + if ~isempty(S.curveEditor) + S.curveEditor.clearPoints(); + else + S.xPix = []; + S.yPix = []; + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); + refreshAll(); + end + addLog('Cleared curve points.'); + end + + function onBeforeReferenceEdit(~, ~) + S.curveEditActive = false; + if ~isempty(S.curveEditor) + S.curveEditor.setActive(false); + end + end + + function onReferenceEditChanged(~, reason) + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); + reasonText = char(string(reason)); + if strcmp(reasonText, 'start') + addLog('Started reference-pixel edit. Double-click two endpoints, then drag endpoints to refine.'); + elseif strcmp(reasonText, 'finish') + addLog('Finished reference-pixel edit.'); + end + refreshScaleReadout(); + refreshSummary(); + end + + function onScaleBarPlaced(~, ~) + scaleBar = scaleTool.placedScaleBar(); + cal = scaleTool.calibration(); + addLog(sprintf('Placed scale bar: %.6g %s (%.6g px).', ... + scaleBar.barLength, cal.unit, scaleBar.barLength * cal.pixelsPerUnit)); + refreshAll(); + end + + function onScaleToolError(titleText, message) + showError(titleText, message); + end + + function onFitCurvature(~, ~) + if numel(S.xPix) < 3 + showError('Not enough points', 'At least 3 curve points are required to fit curvature.'); + return; + end + + try + fitPath = currentCurveFitPoints(); + S.fit = curvature.ops.computeCurvatureFit(S.xPix, S.yPix, scaleTool.calibration(), ... + chkDensify.Value, round(edtDenseN.Value), ... + fitPath(:, 1), fitPath(:, 2)); + S.length = curvature.state.lengthResultFromFit(S.fit); + catch ME + showError('Circle fit failed', ME.message); + return; + end + + if S.fit.ok + addLog(sprintf('Fit complete: R = %.6g %s, curvature = %.6g %s.', ... + S.fit.R_show, S.fit.unitLen, S.fit.kappa_show, S.fit.unitK)); + else + addLog(sprintf('Fit failed: %s', S.fit.message)); + end + refreshAll(); + end + + function onMeasureCurveLength(~, ~) + if numel(S.xPix) < 2 + showError('Not enough points', 'At least 2 curve points are required to measure curve length.'); + return; + end + + points = currentCurveLengthPoints(); + try + S.length = curvature.ops.computeCurveLength(points(:, 1), points(:, 2), ... + scaleTool.calibration()); + catch ME + showError('Curve length failed', ME.message); + return; + end + addLog(sprintf('Curve length measured: %.6g %s.', ... + S.length.length_show, S.length.unitLen)); + refreshAll(); + end + + function onExportCSV(~, ~) + if ~S.fit.ok && ~S.length.ok + showError('No measurement result', ... + 'Fit curvature or measure curve length before exporting a result CSV.'); + return; + end + + [fn, fp] = uiputfile('*.csv', 'Export curvature result CSV', ... + 'curvature_result.csv'); + if isequal(fn, 0) + addLog('Export result CSV cancelled.'); + return; + end + + filepath = string(fullfile(fp, fn)); + try + T = curvature.export.buildResultTable(S.fit, S.imagePath, S.length); + writetable(T, filepath); + catch ME + showError('Could not export result CSV', ME.message); + return; + end + addLog(sprintf('Exported result CSV: %s', filepath)); + end + + function onExportOverlay(~, ~) + if isempty(S.image) + showError('No image loaded', 'Open an image before exporting an overlay.'); + return; + end + + [fn, fp] = uiputfile('*.png', 'Export overlay PNG', 'curvature_overlay.png'); + if isequal(fn, 0) + addLog('Export overlay PNG cancelled.'); + return; + end + + filepath = string(fullfile(fp, fn)); + try + refreshImageOverlay(); + exportgraphics(ui.topAxes, filepath, 'Resolution', 300); + catch ME + showError('Could not export overlay PNG', ME.message); + return; + end + addLog(sprintf('Exported overlay PNG: %s', filepath)); + end + + function refreshAll() + refreshScaleReadout(); + refreshImageOverlay(); + refreshSummary(); + end + + function ensureCurveEditor() + if isempty(S.image) + return; + end + if isempty(S.curveEditor) + S.curveEditor = labkit.ui.tool.anchorEditor(imageRuntime, size(S.image), ... + struct('closed', false, ... + 'style', 'Curve', ... + 'onTrace', debugLog.trace, ... + 'onChanged', @onCurveEditorChanged)); + else + S.curveEditor.setImageSize(size(S.image)); + S.curveEditor.setStyle('Curve'); + end + end + + function onCalibrationSettingsChanged(~, reason) + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); + scaleTool.clearScaleBar(); + if curvature.ui.isReferenceEditReason(reason) + refreshScaleReadout(); + refreshSummary(); + else + refreshAll(); + end + end + + function onScaleBarSettingsChanged(~, ~) + refreshAll(); + end + + function refreshImageOverlayCallback(~, ~) + refreshImageOverlay(); + end + + function points = currentCurveLengthPoints() + points = currentCurveDisplayPoints(2); + end + + function points = currentCurveFitPoints() + points = currentCurveDisplayPoints(3); + end + + function points = currentCurveDisplayPoints(minPointCount) + points = [S.xPix(:), S.yPix(:)]; + if ~isempty(S.curveEditor) + curvePoints = S.curveEditor.curvePoints(); + if size(curvePoints, 1) >= minPointCount + points = curvePoints; + end + end + end + + function updateCurveGraphics() + if ~isempty(S.curveEditor) + S.curveEditor.refresh(); + end + end + + function refreshScaleReadout() + scaleTool.updateReadout(); + end + + function updateModeControls() + hasImage = ~isempty(S.image); + hasCurve = ~isempty(S.xPix); + referenceEditActive = scaleTool.isReferenceEditActive(); + editActive = S.curveEditActive || referenceEditActive; + + btnStartCurve.Enable = curvature.view.ternary(hasImage, 'on', 'off'); + btnStartCurve.Text = curvature.view.ternary(S.curveEditActive, ... + 'Finish curve edit', 'Start curve edit'); + scaleTool.setEnabled(struct( ... + 'hasImage', hasImage, ... + 'blockInputs', S.curveEditActive, ... + 'blockPlacement', editActive)); + + btnUndoPoint.Enable = curvature.view.ternary(hasCurve && ~referenceEditActive, 'on', 'off'); + btnClearCurve.Enable = curvature.view.ternary(hasCurve && ~referenceEditActive, 'on', 'off'); + chkDensify.Enable = curvature.view.ternary(~editActive, 'on', 'off'); + edtDenseN.Enable = curvature.view.ternary(~editActive, 'on', 'off'); + chkShowDense.Enable = curvature.view.ternary(S.fit.ok && ~editActive, 'on', 'off'); + btnFit.Enable = curvature.view.ternary(numel(S.xPix) >= 3 && ~editActive, 'on', 'off'); + btnMeasureLength.Enable = curvature.view.ternary(numel(S.xPix) >= 2 && ~editActive, 'on', 'off'); + btnExportCSV.Enable = curvature.view.ternary((S.fit.ok || S.length.ok) && ~editActive, 'on', 'off'); + btnExportOverlay.Enable = curvature.view.ternary(hasImage && ~editActive, 'on', 'off'); + end + + function refreshImageOverlay() + ax = ui.topAxes; + cla(ax); + hold(ax, 'off'); + + if isempty(S.image) + title(ax, 'Image + Circle Fit'); + axis(ax, 'normal'); + xlim(ax, [0 1]); + ylim(ax, [0 1]); + return; + end + + hImage = labkit.ui.view.drawImage(ui, 'imageAxes', S.image, ... + "title", "Image + Circle Fit", ... + "options", struct('clearAxes', false)); + hold(ax, 'on'); + + if S.fit.ok + t = linspace(-pi, pi, 600); + plot(ax, S.fit.xc_px + S.fit.R_px*cos(t), ... + S.fit.yc_px + S.fit.R_px*sin(t), ... + 'r-', 'LineWidth', 2, ... + 'HitTest', 'off', ... + 'DisplayName', 'fit circle'); + plot(ax, S.fit.xc_px, S.fit.yc_px, 'ro', ... + 'MarkerFaceColor', 'r', ... + 'HitTest', 'off', ... + 'DisplayName', 'center'); + title(ax, sprintf('R = %.4g %s, curvature = %.4g %s, RMSE = %.3g %s', ... + S.fit.R_show, S.fit.unitLen, S.fit.kappa_show, ... + S.fit.unitK, S.fit.rmse_show, S.fit.unitLen)); + else + title(ax, 'Image + Circle Fit'); + end + + if S.curveEditActive + ensureCurveEditor(); + S.curveEditor.setBackground(hImage); + S.curveEditor.refresh(); + elseif scaleTool.isReferenceEditActive() + scaleTool.setBackground(hImage); + scaleTool.refresh(); + else + plotStaticCurveAnchors(ax); + end + + scaleTool.renderOverlay(ax); + hold(ax, 'off'); + end + + function plotStaticCurveAnchors(ax) + points = [S.xPix(:), S.yPix(:)]; + curve = points; + if ~isempty(S.curveEditor) + curve = S.curveEditor.curvePoints(); + end + curvature.view.plotStaticCurveAnchors(ax, points, curve, S.fit, chkShowDense.Value); + end + + function onPreviewScroll(~, event) + if isempty(S.image) + return; + end + point = ui.topAxes.CurrentPoint; + x = point(1, 1); + y = point(1, 2); + if ~curvature.view.insideImageBounds(x, y, size(S.image)) + return; + end + curvature.view.zoomAxesAtPoint(ui.topAxes, x, y, event.VerticalScrollCount, size(S.image)); + end + + function refreshSummary() + summary = curvature.view.summaryViewData(S.imagePath, S.xPix, S.fit, ... + S.length, S.curveEditActive, scaleTool.isReferenceEditActive()); + txtPointCount.Value = summary.pointCountText; + resultTable.Data = summary.tableData; + txtDetails.Value = summary.details; + updateModeControls(); + end + + function resetAxes() + refreshImageOverlay(); + refreshSummary(); + end + + function addLog(message) + labkit.ui.view.appendLog(ui, 'appLog', message); + debugLog.append(message); + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end +end diff --git a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m index 18be305..58e3f2b 100644 --- a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m +++ b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m @@ -17,481 +17,11 @@ 'labkit_CurvatureMeasurement_app returns at most the app figure handle.'); end - S = struct(); - S.imagePath = ""; - S.image = []; - S.xPix = []; - S.yPix = []; - S.curveEditor = []; - S.curveEditActive = false; - S.fit = curvature.state.emptyFitResult(); - S.length = curvature.state.emptyLengthResult(); - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Image Curvature Measurement', ... - 'position', [90 70 1420 860], ... - 'leftWidth', 390, ... - 'options', curvature.ui.appShellOptions())); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - ui.topAxes = uiaxes(ui.rightGrid); - ui.topAxes.Layout.Row = 1; - imageRuntime = labkit.ui.tool.createRuntime(ui.topAxes, ... - struct('figure', fig, ... - 'defaultScrollFcn', @onPreviewScroll, ... - 'onTrace', debugLog.trace)); - - controls = curvature.ui.createControls(layFA, laySR, layLog, imageRuntime, struct( ... - 'onOpenImage', @onOpenImage, ... - 'onStartCurveEdit', @onStartCurveEdit, ... - 'onUndoCurvePoint', @onUndoCurvePoint, ... - 'onClearCurve', @onClearCurve, ... - 'onBeforeReferenceEdit', @onBeforeReferenceEdit, ... - 'onReferenceEditChanged', @onReferenceEditChanged, ... - 'onCalibrationSettingsChanged', @onCalibrationSettingsChanged, ... - 'onScaleBarSettingsChanged', @onScaleBarSettingsChanged, ... - 'onScaleBarPlaced', @onScaleBarPlaced, ... - 'onScaleToolError', @onScaleToolError, ... - 'onShowDenseChanged', @(~,~) refreshImageOverlay(), ... - 'onFitCurvature', @onFitCurvature, ... - 'onMeasureCurveLength', @onMeasureCurveLength, ... - 'onExportCSV', @onExportCSV, ... - 'onExportOverlay', @onExportOverlay, ... - 'onTrace', debugLog.trace)); - txtImage = controls.txtImage; - txtPointCount = controls.txtPointCount; - btnStartCurve = controls.btnStartCurve; - btnUndoPoint = controls.btnUndoPoint; - btnClearCurve = controls.btnClearCurve; - scaleTool = controls.scaleTool; - chkDensify = controls.chkDensify; - edtDenseN = controls.edtDenseN; - chkShowDense = controls.chkShowDense; - btnFit = controls.btnFit; - btnMeasureLength = controls.btnMeasureLength; - btnExportCSV = controls.btnExportCSV; - btnExportOverlay = controls.btnExportOverlay; - resultTable = controls.resultTable; - txtDetails = controls.txtDetails; - txtLog = controls.txtLog; - - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('Curvature measurement debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - resetAxes(); - refreshScaleReadout(); - + fig = curvature.run(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenImage(~, ~) - [fn, fp] = uigetfile( ... - {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', 'Image files'}, ... - 'Select image'); - if isequal(fn, 0) - addLog('Image selection cancelled.'); - return; - end - - filepath = string(fullfile(fp, fn)); - try - img = imread(filepath); - catch ME - showError('Could not read image', ME.message); - return; - end - - S.imagePath = filepath; - S.image = img; - S.xPix = []; - S.yPix = []; - scaleTool.resetForNewImage(size(S.image)); - S.curveEditActive = false; - if ~isempty(S.curveEditor) - S.curveEditor.delete(); - end - S.curveEditor = []; - S.fit = curvature.state.emptyFitResult(); - S.length = curvature.state.emptyLengthResult(); - txtImage.Value = char(filepath); - addLog(sprintf('Loaded image: %s', filepath)); - refreshAll(); - end - - function onStartCurveEdit(~, ~) - if isempty(S.image) - showError('No image loaded', 'Open an image before editing curve points.'); - return; - end - - if S.curveEditActive - S.curveEditActive = false; - if ~isempty(S.curveEditor) - S.curveEditor.setActive(false); - end - addLog('Finished curve edit.'); - refreshAll(); - return; - end - - scaleTool.finishReferenceEdit(false); - S.curveEditActive = true; - ensureCurveEditor(); - S.curveEditor.start([S.xPix(:), S.yPix(:)]); - S.fit = curvature.state.emptyFitResult(); - addLog('Started curve edit. Double-click blank image space to add/insert points; drag points to move; double-click a point to delete it.'); - refreshAll(); - end - - function onCurveEditorChanged(points, reason) - S.xPix = points(:, 1); - S.yPix = points(:, 2); - S.fit = curvature.state.emptyFitResult(); - S.length = curvature.state.emptyLengthResult(); - refreshSummary(); - if any(strcmp(reason, {'add point', 'delete point', 'move point'})) - addLog(sprintf('Curve edit updated: %d point(s).', numel(S.xPix))); - end - end - - function onUndoCurvePoint(~, ~) - if ~isempty(S.curveEditor) - S.curveEditor.undoLast(); - end - end - - function onClearCurve(~, ~) - if ~isempty(S.curveEditor) - S.curveEditor.clearPoints(); - else - S.xPix = []; - S.yPix = []; - S.fit = curvature.state.emptyFitResult(); - S.length = curvature.state.emptyLengthResult(); - refreshAll(); - end - addLog('Cleared curve points.'); - end - - function onBeforeReferenceEdit(~, ~) - S.curveEditActive = false; - if ~isempty(S.curveEditor) - S.curveEditor.setActive(false); - end - end - - function onReferenceEditChanged(~, reason) - S.fit = curvature.state.emptyFitResult(); - S.length = curvature.state.emptyLengthResult(); - reasonText = char(string(reason)); - if strcmp(reasonText, 'start') - addLog('Started reference-pixel edit. Double-click two endpoints, then drag endpoints to refine.'); - elseif strcmp(reasonText, 'finish') - addLog('Finished reference-pixel edit.'); - end - refreshScaleReadout(); - refreshSummary(); - end - - function onScaleBarPlaced(~, ~) - scaleBar = scaleTool.placedScaleBar(); - cal = scaleTool.calibration(); - addLog(sprintf('Placed scale bar: %.6g %s (%.6g px).', ... - scaleBar.barLength, cal.unit, scaleBar.barLength * cal.pixelsPerUnit)); - refreshAll(); - end - - function onScaleToolError(titleText, message) - showError(titleText, message); - end - - function onFitCurvature(~, ~) - if numel(S.xPix) < 3 - showError('Not enough points', 'At least 3 curve points are required to fit curvature.'); - return; - end - - try - fitPath = currentCurveFitPoints(); - S.fit = curvature.ops.computeCurvatureFit(S.xPix, S.yPix, scaleTool.calibration(), ... - chkDensify.Value, round(edtDenseN.Value), ... - fitPath(:, 1), fitPath(:, 2)); - S.length = curvature.state.lengthResultFromFit(S.fit); - catch ME - showError('Circle fit failed', ME.message); - return; - end - - if S.fit.ok - addLog(sprintf('Fit complete: R = %.6g %s, curvature = %.6g %s.', ... - S.fit.R_show, S.fit.unitLen, S.fit.kappa_show, S.fit.unitK)); - else - addLog(sprintf('Fit failed: %s', S.fit.message)); - end - refreshAll(); - end - - function onMeasureCurveLength(~, ~) - if numel(S.xPix) < 2 - showError('Not enough points', 'At least 2 curve points are required to measure curve length.'); - return; - end - - points = currentCurveLengthPoints(); - try - S.length = curvature.ops.computeCurveLength(points(:, 1), points(:, 2), ... - scaleTool.calibration()); - catch ME - showError('Curve length failed', ME.message); - return; - end - addLog(sprintf('Curve length measured: %.6g %s.', ... - S.length.length_show, S.length.unitLen)); - refreshAll(); - end - - function onExportCSV(~, ~) - if ~S.fit.ok && ~S.length.ok - showError('No measurement result', ... - 'Fit curvature or measure curve length before exporting a result CSV.'); - return; - end - - [fn, fp] = uiputfile('*.csv', 'Export curvature result CSV', ... - 'curvature_result.csv'); - if isequal(fn, 0) - addLog('Export result CSV cancelled.'); - return; - end - - filepath = string(fullfile(fp, fn)); - try - T = curvature.export.buildResultTable(S.fit, S.imagePath, S.length); - writetable(T, filepath); - catch ME - showError('Could not export result CSV', ME.message); - return; - end - addLog(sprintf('Exported result CSV: %s', filepath)); - end - - function onExportOverlay(~, ~) - if isempty(S.image) - showError('No image loaded', 'Open an image before exporting an overlay.'); - return; - end - - [fn, fp] = uiputfile('*.png', 'Export overlay PNG', 'curvature_overlay.png'); - if isequal(fn, 0) - addLog('Export overlay PNG cancelled.'); - return; - end - - filepath = string(fullfile(fp, fn)); - try - refreshImageOverlay(); - exportgraphics(ui.topAxes, filepath, 'Resolution', 300); - catch ME - showError('Could not export overlay PNG', ME.message); - return; - end - addLog(sprintf('Exported overlay PNG: %s', filepath)); - end - - function refreshAll() - refreshScaleReadout(); - refreshImageOverlay(); - refreshSummary(); - end - - function ensureCurveEditor() - if isempty(S.image) - return; - end - if isempty(S.curveEditor) - S.curveEditor = labkit.ui.tool.anchorEditor(imageRuntime, size(S.image), ... - struct('closed', false, ... - 'style', 'Curve', ... - 'onTrace', debugLog.trace, ... - 'onChanged', @onCurveEditorChanged)); - else - S.curveEditor.setImageSize(size(S.image)); - S.curveEditor.setStyle('Curve'); - end - end - - function onCalibrationSettingsChanged(~, reason) - S.fit = curvature.state.emptyFitResult(); - S.length = curvature.state.emptyLengthResult(); - scaleTool.clearScaleBar(); - if curvature.ui.isReferenceEditReason(reason) - refreshScaleReadout(); - refreshSummary(); - else - refreshAll(); - end - end - - function onScaleBarSettingsChanged(~, ~) - refreshAll(); - end - - function points = currentCurveLengthPoints() - points = currentCurveDisplayPoints(2); - end - - function points = currentCurveFitPoints() - points = currentCurveDisplayPoints(3); - end - - function points = currentCurveDisplayPoints(minPointCount) - points = [S.xPix(:), S.yPix(:)]; - if ~isempty(S.curveEditor) - curvePoints = S.curveEditor.curvePoints(); - if size(curvePoints, 1) >= minPointCount - points = curvePoints; - end - end - end - - function updateCurveGraphics() - if ~isempty(S.curveEditor) - S.curveEditor.refresh(); - end - end - - function refreshScaleReadout() - scaleTool.updateReadout(); - end - - function updateModeControls() - hasImage = ~isempty(S.image); - hasCurve = ~isempty(S.xPix); - referenceEditActive = scaleTool.isReferenceEditActive(); - editActive = S.curveEditActive || referenceEditActive; - - btnStartCurve.Enable = curvature.view.ternary(hasImage, 'on', 'off'); - btnStartCurve.Text = curvature.view.ternary(S.curveEditActive, ... - 'Finish curve edit', 'Start curve edit'); - scaleTool.setEnabled(struct( ... - 'hasImage', hasImage, ... - 'blockInputs', S.curveEditActive, ... - 'blockPlacement', editActive)); - - btnUndoPoint.Enable = curvature.view.ternary(hasCurve && ~referenceEditActive, 'on', 'off'); - btnClearCurve.Enable = curvature.view.ternary(hasCurve && ~referenceEditActive, 'on', 'off'); - chkDensify.Enable = curvature.view.ternary(~editActive, 'on', 'off'); - edtDenseN.Enable = curvature.view.ternary(~editActive, 'on', 'off'); - chkShowDense.Enable = curvature.view.ternary(S.fit.ok && ~editActive, 'on', 'off'); - btnFit.Enable = curvature.view.ternary(numel(S.xPix) >= 3 && ~editActive, 'on', 'off'); - btnMeasureLength.Enable = curvature.view.ternary(numel(S.xPix) >= 2 && ~editActive, 'on', 'off'); - btnExportCSV.Enable = curvature.view.ternary((S.fit.ok || S.length.ok) && ~editActive, 'on', 'off'); - btnExportOverlay.Enable = curvature.view.ternary(hasImage && ~editActive, 'on', 'off'); - end - - function refreshImageOverlay() - ax = ui.topAxes; - cla(ax); - hold(ax, 'off'); - - if isempty(S.image) - title(ax, 'Image + Circle Fit'); - axis(ax, 'normal'); - xlim(ax, [0 1]); - ylim(ax, [0 1]); - return; - end - - hImage = labkit.ui.view.draw(ax, 'image', S.image, 'Image + Circle Fit', ... - struct('clearAxes', false)); - hold(ax, 'on'); - - if S.fit.ok - t = linspace(-pi, pi, 600); - plot(ax, S.fit.xc_px + S.fit.R_px*cos(t), ... - S.fit.yc_px + S.fit.R_px*sin(t), ... - 'r-', 'LineWidth', 2, ... - 'HitTest', 'off', ... - 'DisplayName', 'fit circle'); - plot(ax, S.fit.xc_px, S.fit.yc_px, 'ro', ... - 'MarkerFaceColor', 'r', ... - 'HitTest', 'off', ... - 'DisplayName', 'center'); - title(ax, sprintf('R = %.4g %s, curvature = %.4g %s, RMSE = %.3g %s', ... - S.fit.R_show, S.fit.unitLen, S.fit.kappa_show, ... - S.fit.unitK, S.fit.rmse_show, S.fit.unitLen)); - else - title(ax, 'Image + Circle Fit'); - end - - if S.curveEditActive - ensureCurveEditor(); - S.curveEditor.setBackground(hImage); - S.curveEditor.refresh(); - elseif scaleTool.isReferenceEditActive() - scaleTool.setBackground(hImage); - scaleTool.refresh(); - else - plotStaticCurveAnchors(ax); - end - - scaleTool.renderOverlay(ax); - hold(ax, 'off'); - labkit.ui.view.draw(ax, 'popout'); - end - - function plotStaticCurveAnchors(ax) - points = [S.xPix(:), S.yPix(:)]; - curve = points; - if ~isempty(S.curveEditor) - curve = S.curveEditor.curvePoints(); - end - curvature.view.plotStaticCurveAnchors(ax, points, curve, S.fit, chkShowDense.Value); - end - - function onPreviewScroll(~, event) - if isempty(S.image) - return; - end - point = ui.topAxes.CurrentPoint; - x = point(1, 1); - y = point(1, 2); - if ~curvature.view.insideImageBounds(x, y, size(S.image)) - return; - end - curvature.view.zoomAxesAtPoint(ui.topAxes, x, y, event.VerticalScrollCount, size(S.image)); - end - - function refreshSummary() - summary = curvature.view.summaryViewData(S.imagePath, S.xPix, S.fit, ... - S.length, S.curveEditActive, scaleTool.isReferenceEditActive()); - txtPointCount.Value = summary.pointCountText; - resultTable.Data = summary.tableData; - txtDetails.Value = summary.details; - updateModeControls(); - end - - function resetAxes() - refreshImageOverlay(); - refreshSummary(); - end - - function addLog(message) - labkit.ui.view.update(txtLog, 'appendLog', message); - debugLog.append(message); - end - - function showError(titleText, message) - addLog(sprintf('%s: %s', titleText, message)); - uialert(fig, message, titleText); - end end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+ui/buildSpec.m b/apps/image_measurement/focus_stack/+focus_stack/+ui/buildSpec.m new file mode 100644 index 0000000..25d170c --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+ui/buildSpec.m @@ -0,0 +1,99 @@ +% Expected caller: labkit_FocusStack_app. Inputs are preset labels, +% workflow-note lines, and callback handles. Output is a data-only UI 2.0 +% workbench spec for the Focus Stack app. +function spec = buildSpec(fusionPresets, workflowNotes, callbacks) + + spec = labkit.ui.spec.app('focusStackApp', 'Microscope Focus Stack Fusion', ... + 'position', [80 60 1440 860], ... + 'leftWidth', 390, ... + 'controlTabs', { ... + labkit.ui.spec.tab('filesAnalysis', 'Files + Analysis', { ... + labkit.ui.spec.section('imagesSection', 'Images', { ... + labkit.ui.spec.action('openImageFolder', 'Open image folder', ... + callbackValue(callbacks, 'openImageFolder')), ... + labkit.ui.spec.field('sourceLocation', 'Source', ... + 'kind', 'readonly', ... + 'value', 'No images loaded'), ... + labkit.ui.spec.pathPanel('sourceImages', 'Selected images', ... + 'mode', 'multiFile', ... + 'selectionMode', 'single', ... + 'filters', focus_stack.io.imageDialogFilter(), ... + 'status', 'No images loaded', ... + 'emptyText', 'No images loaded', ... + 'height', 'flex', ... + 'onChoose', callbackValue(callbacks, 'sourceImagesChosen'), ... + 'onClear', callbackValue(callbacks, 'clearImages'))}, ... + 'height', 250), ... + labkit.ui.spec.section('fusionOptionsSection', 'Fusion Options', { ... + labkit.ui.spec.field('fusionPreset', 'Preset', ... + 'kind', 'dropdown', ... + 'items', fusionPresets, ... + 'value', fusionPresets{1}, ... + 'onChange', callbackValue(callbacks, 'fusionPresetChanged')), ... + labkit.ui.spec.field('autoRegister', ... + 'Auto-register stack to middle image', ... + 'kind', 'checkbox', ... + 'value', false), ... + labkit.ui.spec.field('focusWindow', 'Detail scale (px)', ... + 'kind', 'spinner', ... + 'value', 31, ... + 'limits', [3 99], ... + 'step', 2), ... + labkit.ui.spec.field('smoothRadius', 'Blend radius (px)', ... + 'kind', 'spinner', ... + 'value', 4, ... + 'limits', [0 50], ... + 'step', 1), ... + labkit.ui.spec.field('uncertainBlend', 'Uncertain blend (%)', ... + 'kind', 'spinner', ... + 'value', 5, ... + 'limits', [0 100], ... + 'step', 1), ... + labkit.ui.spec.action('runFocusStack', 'Run focus stack', ... + callbackValue(callbacks, 'runFocusStack'), ... + 'enabled', false)}, ... + 'height', 235), ... + labkit.ui.spec.section('exportSection', 'Export', { ... + labkit.ui.spec.action('exportFused', 'Export fused PNG', ... + callbackValue(callbacks, 'exportFused'), ... + 'enabled', false), ... + labkit.ui.spec.action('exportFocusMap', 'Export focus map PNG', ... + callbackValue(callbacks, 'exportFocusMap'), ... + 'enabled', false), ... + labkit.ui.spec.action('exportSummary', 'Export summary CSV', ... + callbackValue(callbacks, 'exportSummary'), ... + 'enabled', false)}, ... + 'height', 185), ... + labkit.ui.spec.section('workflowNotesSection', 'Workflow Notes', { ... + labkit.ui.spec.statusPanel('workflowNotes', 'Workflow Notes', ... + 'value', workflowNotes, ... + 'height', 'flex')}, ... + 'height', 170)}), ... + labkit.ui.spec.tab('summaryResults', 'Summary + Results', { ... + labkit.ui.spec.section('summaryTableSection', 'Summary', { ... + labkit.ui.spec.resultTable('resultTable', 'Summary', ... + 'columns', {'Metric', 'Value'}, ... + 'data', focus_stack.view.initialResultTable())}, ... + 'height', 220), ... + labkit.ui.spec.section('detailsSection', 'Details', { ... + labkit.ui.spec.statusPanel('details', 'Details', ... + 'value', {'Load a focus image folder or select image files to begin.'}, ... + 'height', 'flex')}, ... + 'height', 'flex')}), ... + labkit.ui.spec.tab('log', 'Log', { ... + labkit.ui.spec.section('logSection', 'Log', { ... + labkit.ui.spec.logPanel('logPanel', 'Log')}, ... + 'height', 'flex')})}, ... + 'workspace', labkit.ui.spec.workspace('workspace', 'Focus Stack Preview', { ... + labkit.ui.spec.previewArea('preview', 'Focus Stack Preview', ... + 'layout', 'pair', ... + 'axisIds', {'fused', 'focusMap'}, ... + 'axisTitles', {'Fused all-in-focus image', 'Focus-depth index map'})})); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+ui/createRightAxesPair.m b/apps/image_measurement/focus_stack/+focus_stack/+ui/createRightAxesPair.m deleted file mode 100644 index 041af69..0000000 --- a/apps/image_measurement/focus_stack/+focus_stack/+ui/createRightAxesPair.m +++ /dev/null @@ -1,46 +0,0 @@ -% App-owned focus-stack preview layout helper. Expected caller: -% labkit_FocusStack_app. Inputs are the shell UI struct, axes titles, and -% whether plot-control panels are needed. Output is the UI struct with -% top/bottom axes and panel fields. Side effects are limited to creating axes -% and optional panels on the shell right grid. -function ui = createRightAxesPair(ui, topTitle, bottomTitle, showControls) -%CREATERIGHTAXESPAIR Create focus-stack preview axes. - - if showControls - ui.topControlsPanel = uipanel(ui.rightGrid, 'Title', topTitle); - ui.topControlsPanel.Layout.Row = 1; - ui.topAxes = createOneAxes(ui.rightGrid, 2, topTitle); - - ui.bottomControlsPanel = uipanel(ui.rightGrid, 'Title', bottomTitle); - ui.bottomControlsPanel.Layout.Row = 3; - ui.bottomAxes = createOneAxes(ui.rightGrid, 4, bottomTitle); - else - ui.topControlsPanel = []; - ui.bottomControlsPanel = []; - ui.topAxes = createOneAxes(ui.rightGrid, 1, topTitle); - ui.bottomAxes = createOneAxes(ui.rightGrid, 2, bottomTitle); - end -end - -function ax = createOneAxes(parent, row, titleText) - ax = uiaxes(parent); - ax.Layout.Row = row; - title(ax, titleText); - labkit.ui.view.draw(ax, 'popout'); - disableAxesInteractivity(ax); -end - -function disableAxesInteractivity(ax) - try - disableDefaultInteractivity(ax); - catch - end - try - ax.Interactions = []; - catch - end - try - ax.Toolbar.Visible = 'off'; - catch - end -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNames.m b/apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNames.m deleted file mode 100644 index efce602..0000000 --- a/apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNames.m +++ /dev/null @@ -1,12 +0,0 @@ -% App-owned focus-stack display-name helper. Expected caller: -% labkit_FocusStack_app list refresh. Input is a path vector. Output is a cell -% column of display names. This helper has no side effects. -function names = displayImageNames(paths) -%DISPLAYIMAGENAMES Return display names for focus-stack paths. - - paths = string(paths(:)); - names = cell(numel(paths), 1); - for k = 1:numel(paths) - names{k} = focus_stack.view.displayNameFromPath(paths(k)); - end -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+view/ternary.m b/apps/image_measurement/focus_stack/+focus_stack/+view/ternary.m deleted file mode 100644 index 81365f3..0000000 --- a/apps/image_measurement/focus_stack/+focus_stack/+view/ternary.m +++ /dev/null @@ -1,12 +0,0 @@ -% App-owned focus-stack conditional helper. Expected caller: -% labkit_FocusStack_app UI state refresh. Inputs are condition and two values. -% Output is the selected value. This helper has no side effects. -function value = ternary(condition, trueValue, falseValue) -%TERNARY Return trueValue or falseValue from a scalar condition. - - if condition - value = trueValue; - else - value = falseValue; - end -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/run.m b/apps/image_measurement/focus_stack/+focus_stack/run.m new file mode 100644 index 0000000..7c0a225 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/run.m @@ -0,0 +1,345 @@ +% Expected caller: labkit_FocusStack_app. Input is the debug context prepared +% by the public launcher. Output is the app figure. Side effects are GUI +% creation, user-driven image loading, focus-stack export, and debug trace attachment. +function fig = run(debugLog) +%RUN Build and run the Focus Stack app body. + + S = struct(); + S.folder = ""; + S.paths = strings(0, 1); + S.images = {}; + S.alignedImages = {}; + S.registrationLines = {}; + S.result = focus_stack.state.emptyResult(); + + fusionPresets = {'Balanced', 'Crisp details', 'Smooth transitions', 'Noisy images'}; + workflowNotes = { ... + '1. Load a folder or select one or more image files from the same microscope field of view.', ... + '2. Use file selection when a folder contains bad frames that should be excluded.', ... + '3. Start with Balanced. Use Crisp for fine texture, Smooth for visible seams, Noisy for grainy images.', ... + '4. Detail scale controls feature size; Blend radius controls seam softness; Uncertain blend softens low-texture areas.'}; + callbacks = struct( ... + 'openImageFolder', @onOpenFolder, ... + 'sourceImagesChosen', @onOpenFilesChosen, ... + 'clearImages', @onClearImages, ... + 'fusionPresetChanged', @onFusionPresetChanged, ... + 'runFocusStack', @onRunFocusStack, ... + 'exportFused', @onExportFused, ... + 'exportFocusMap', @onExportFocusMap, ... + 'exportSummary', @onExportSummary); + spec = focus_stack.ui.buildSpec(fusionPresets, workflowNotes, callbacks); + ui = labkit.ui.app.create(spec, 'debug', debugLog); + fig = ui.figure; + if debugLog.enabled + debugLog.trace('Focus stack debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + resetPreviewAxes(); + refreshSummary(); + + function onOpenFolder(~, ~) + folder = uigetdir(pwd, 'Select focus image folder'); + if isequal(folder, 0) + addLog('Image folder selection cancelled.'); + return; + end + loadImageFolder(string(folder)); + end + + function onOpenFilesChosen(~, event) + loadImagePaths(string(event.paths(:)), string(fileparts(event.paths{1})), ... + sprintf('Selected image files from %s', char(fileparts(event.paths{1}))), ... + sprintf('Loaded %d selected image file(s).', numel(event.paths))); + end + + function onClearImages(~, ~) + S.folder = ""; + S.paths = strings(0, 1); + S.images = {}; + S.alignedImages = {}; + S.registrationLines = {}; + S.result = focus_stack.state.emptyResult(); + addLog('Cleared loaded focus images and results.'); + refreshSourcePanel(); + refreshPreview(); + refreshSummary(); + end + + function loadImageFolder(folder) + try + paths = focus_stack.io.findImages(folder); + catch ME + showError('Could not load focus stack', ME.message); + return; + end + loadImagePaths(paths, folder, char(folder), ... + sprintf('Loaded %d image(s) from folder.', numel(paths))); + end + + function loadImagePaths(paths, sourceFolder, sourceDescription, logMessage) + try + images = focus_stack.io.readImages(paths); + catch ME + showError('Could not load focus stack', ME.message); + return; + end + + S.paths = string(paths(:)); + S.images = images; + S.alignedImages = {}; + S.registrationLines = {}; + S.result = focus_stack.state.emptyResult(); + S.folder = string(sourceFolder); + + labkit.ui.view.setValue(ui, 'sourceLocation', char(string(sourceDescription))); + addLog(logMessage); + refreshSourcePanel(); + refreshPreview(); + refreshSummary(); + end + + function onRunFocusStack(~, ~) + if numel(S.images) < 2 + showError('Not enough images', 'Load at least two images before running focus stacking.'); + return; + end + + opts = currentFusionOptions(); + registerStack = labkit.ui.view.getValue(ui, 'autoRegister'); + busyOpts = struct(); + busyOpts.title = 'Focus stacking'; + busyOpts.message = 'Fusing selected microscope images...'; + busyOpts.controls = focusStackBusyControls(); + try + payload = labkit.ui.app.runBusy(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) + addLog(S.registrationLines{k}); + end + refreshPreview(); + refreshSummary(); + end + + function opts = currentFusionOptions() + opts = struct(); + opts.focusWindow = round(labkit.ui.view.getValue(ui, 'focusWindow')); + opts.smoothRadius = round(labkit.ui.view.getValue(ui, 'smoothRadius')); + opts.minConfidence = labkit.ui.view.getValue(ui, 'uncertainBlend') / 100; + end + + function onFusionPresetChanged(~, ~) + settings = focus_stack.state.fusionPresetSettings( ... + labkit.ui.view.getValue(ui, 'fusionPreset')); + labkit.ui.view.setValue(ui, 'focusWindow', settings.focusWindow); + labkit.ui.view.setValue(ui, 'smoothRadius', settings.smoothRadius); + labkit.ui.view.setValue(ui, 'uncertainBlend', settings.minConfidencePercent); + addLog(sprintf('Fusion preset set to %s.', ... + labkit.ui.view.getValue(ui, 'fusionPreset'))); + end + + function payload = runFocusStackComputation(opts, registerStack) + imagesForFusion = S.images; + registrationLines = {}; + if registerStack + [imagesForFusion, registrationLines] = focus_stack.ops.alignImages(S.images); + end + + payload = struct(); + payload.imagesForFusion = imagesForFusion; + payload.registrationLines = registrationLines; + payload.result = focus_stack.ops.computeFocusStack(imagesForFusion, opts); + end + + function controls = focusStackBusyControls() + controls = { ... + ui.controls.openImageFolder.button, ... + ui.controls.sourceImages.chooseButton, ... + ui.controls.sourceImages.clearButton, ... + ui.controls.sourceImages.listbox, ... + ui.controls.fusionPreset.handle, ... + ui.controls.autoRegister.handle, ... + ui.controls.focusWindow.handle, ... + ui.controls.smoothRadius.handle, ... + ui.controls.uncertainBlend.handle, ... + ui.controls.runFocusStack.button, ... + ui.controls.exportFused.button, ... + ui.controls.exportFocusMap.button, ... + ui.controls.exportSummary.button}; + end + + function onExportFused(~, ~) + if ~S.result.ok + showError('No fused image', 'Run focus stack before exporting the fused PNG.'); + return; + end + filepath = chooseSavePath('Export fused PNG', 'focus_stack_fused.png'); + if filepath == "" + addLog('Export fused PNG cancelled.'); + return; + end + try + imwrite(S.result.fused, filepath); + catch ME + showError('Could not export fused PNG', ME.message); + return; + end + addLog(sprintf('Exported fused PNG: %s', filepath)); + end + + function onExportFocusMap(~, ~) + if ~S.result.ok + showError('No focus map', 'Run focus stack before exporting the focus map PNG.'); + return; + end + filepath = chooseSavePath('Export focus map PNG', 'focus_stack_map.png'); + if filepath == "" + addLog('Export focus map PNG cancelled.'); + return; + end + try + imwrite(focus_stack.view.focusIndexRgb(S.result.focusIndex, S.result.inputCount), filepath); + catch ME + showError('Could not export focus map PNG', ME.message); + return; + end + addLog(sprintf('Exported focus map PNG: %s', filepath)); + end + + function onExportSummary(~, ~) + if ~S.result.ok + showError('No summary', 'Run focus stack before exporting the summary CSV.'); + return; + end + filepath = chooseSavePath('Export summary CSV', 'focus_stack_summary.csv'); + if filepath == "" + addLog('Export summary CSV cancelled.'); + return; + end + try + T = focus_stack.export.buildSummaryTable(S.result, S.paths); + writetable(T, filepath); + catch ME + showError('Could not export summary CSV', ME.message); + return; + end + addLog(sprintf('Exported summary CSV: %s', filepath)); + end + + function filepath = chooseSavePath(titleText, defaultName) + defaultPath = fullfile(defaultSaveFolder(), defaultName); + [fn, fp] = uiputfile({'*.png;*.csv', 'Export files'}, titleText, defaultPath); + if isequal(fn, 0) + filepath = ""; + else + filepath = string(fullfile(fp, fn)); + end + end + + function folder = defaultSaveFolder() + folder = char(S.folder); + if isempty(folder) || exist(folder, 'dir') ~= 7 + folder = pwd; + end + end + + function refreshSourcePanel() + if isempty(S.images) + labkit.ui.view.setValue(ui, 'sourceImages', {}); + labkit.ui.view.setValue(ui, 'sourceLocation', 'No images loaded'); + return; + end + + labkit.ui.view.setValue(ui, 'sourceImages', cellstr(S.paths)); + end + + function refreshPreview() + if S.result.ok + labkit.ui.view.drawImage(ui, 'preview', S.result.fused, ... + 'axis', 'fused', 'title', 'Fused all-in-focus image'); + labkit.ui.view.drawImage(ui, 'preview', ... + focus_stack.view.focusIndexRgb(S.result.focusIndex, S.result.inputCount), ... + 'axis', 'focusMap', 'title', 'Focus-depth index map'); + elseif ~isempty(S.images) + labkit.ui.view.drawImage(ui, 'preview', ... + focus_stack.view.previewImage(S.images{1}), ... + 'axis', 'fused', 'title', 'First source image'); + labkit.ui.view.resetAxes(ui, 'preview', 'Focus-depth index map', true, 'focusMap'); + else + resetPreviewAxes(); + end + updateControls(); + end + + function refreshSummary() + if S.result.ok + ui.controls.resultTable.table.Data = focus_stack.view.resultTableData(S.result); + labkit.ui.view.setValue(ui, 'details', ... + focus_stack.view.details(S.result, S.paths, S.registrationLines)); + elseif numel(S.images) >= 2 + ui.controls.resultTable.table.Data = focus_stack.view.initialResultTable(); + labkit.ui.view.setValue(ui, 'details', { ... + sprintf('Loaded images: %d', numel(S.images)), ... + 'Run focus stack to compute the fused image and focus-depth map.'}); + elseif ~isempty(S.images) + ui.controls.resultTable.table.Data = focus_stack.view.initialResultTable(); + labkit.ui.view.setValue(ui, 'details', { ... + sprintf('Loaded images: %d', numel(S.images)), ... + 'Load at least two images before running focus stack.'}); + else + ui.controls.resultTable.table.Data = focus_stack.view.initialResultTable(); + labkit.ui.view.setValue(ui, 'details', ... + {'Load a focus image folder or select image files to begin.'}); + end + updateControls(); + end + + function updateControls() + hasImages = ~isempty(S.images); + hasStack = numel(S.images) >= 2; + hasResult = S.result.ok; + ui.controls.sourceImages.clearButton.Enable = onOff(hasImages); + ui.controls.sourceImages.listbox.Enable = onOff(hasImages); + labkit.ui.view.setEnabled(ui, 'runFocusStack', hasStack); + labkit.ui.view.setEnabled(ui, 'exportFused', hasResult); + labkit.ui.view.setEnabled(ui, 'exportFocusMap', hasResult); + labkit.ui.view.setEnabled(ui, 'exportSummary', hasResult); + end + + function resetPreviewAxes() + labkit.ui.view.resetAxes(ui, 'preview', 'Fused all-in-focus image', true, 'fused'); + labkit.ui.view.resetAxes(ui, 'preview', 'Focus-depth index map', true, 'focusMap'); + end + + function addLog(message) + labkit.ui.view.appendLog(ui, 'logPanel', message); + debugLog.append(message); + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end +end + +function text = onOff(value) + if islogical(value) && isscalar(value) + if value + text = 'on'; + else + text = 'off'; + end + else + text = char(string(value)); + end +end diff --git a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m index b92818e..8202468 100644 --- a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m +++ b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m @@ -17,447 +17,11 @@ 'labkit_FocusStack_app returns at most the app figure handle.'); end - S = struct(); - S.folder = ""; - S.paths = strings(0, 1); - S.images = {}; - S.alignedImages = {}; - S.registrationLines = {}; - S.result = focus_stack.state.emptyResult(); - - workbenchOpts = struct( ... - 'rightTitle', 'Focus Stack Preview', ... - 'rightGridSize', [2 1], ... - 'rightRowHeight', {{'1x', '1x'}}, ... - 'rightRowSpacing', 10); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... - {250, 235, 185, 170}, ... - struct('resizeOptions', struct('minTopHeight', 130, 'minBottomHeight', 90))), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {220, '1x'}), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Microscope Focus Stack Fusion', ... - 'position', [80 60 1440 860], ... - 'leftWidth', 390, ... - 'options', workbenchOpts)); - ui = focus_stack.ui.createRightAxesPair(ui, ... - 'Fused all-in-focus image', 'Focus-depth index map', false); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - filePanel = labkit.ui.view.section(layFA, 'Images', 1, [4 2], ... - struct('rowHeight', {{'fit', 'fit', 105, 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - fileGrid = filePanel.grid; - - btnOpenFolder = uibutton(fileGrid, 'Text', 'Open image folder', ... - 'ButtonPushedFcn', @onOpenFolder); - btnOpenFolder.Layout.Row = 1; - btnOpenFolder.Layout.Column = 1; - - btnOpenFiles = uibutton(fileGrid, 'Text', 'Open image files', ... - 'ButtonPushedFcn', @onOpenFiles); - btnOpenFiles.Layout.Row = 1; - btnOpenFiles.Layout.Column = 2; - - txtFolder = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No images loaded')); - txtFolder.Layout.Row = 2; - txtFolder.Layout.Column = [1 2]; - - lbImages = uilistbox(fileGrid, 'Items', {'No images loaded'}); - lbImages.Layout.Row = 3; - lbImages.Layout.Column = [1 2]; - - txtStackStatus = labkit.ui.view.form(fileGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'Images: 0')); - txtStackStatus.Layout.Row = 4; - txtStackStatus.Layout.Column = [1 2]; - - analysisPanel = labkit.ui.view.section(layFA, 'Fusion Options', 2, [6 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{155, '1x'}})); - analysisGrid = analysisPanel.grid; - - [lblFusionPreset, ddFusionPreset] = labkit.ui.view.form(analysisGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Preset:', ... - 'items', {{'Balanced', 'Crisp details', 'Smooth transitions', 'Noisy images'}}, ... - 'value', 'Balanced', ... - 'callback', @onFusionPresetChanged)); - lblFusionPreset.Layout.Row = 1; - lblFusionPreset.Layout.Column = 1; - ddFusionPreset.Layout.Row = 1; - ddFusionPreset.Layout.Column = 2; - - chkRegister = uicheckbox(analysisGrid, ... - 'Text', 'Auto-register stack to middle image', ... - 'Value', false); - chkRegister.Layout.Row = 2; - chkRegister.Layout.Column = [1 2]; - - [lblFocusWindow, edtFocusWindow] = labkit.ui.view.form(analysisGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Detail scale (px):', ... - 'value', 31, ... - 'limits', [3 99], ... - 'step', 2)); - lblFocusWindow.Layout.Row = 3; - lblFocusWindow.Layout.Column = 1; - edtFocusWindow.Layout.Row = 3; - edtFocusWindow.Layout.Column = 2; - - [lblSmoothRadius, edtSmoothRadius] = labkit.ui.view.form(analysisGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Blend radius (px):', ... - 'value', 4, ... - 'limits', [0 50], ... - 'step', 1)); - lblSmoothRadius.Layout.Row = 4; - lblSmoothRadius.Layout.Column = 1; - edtSmoothRadius.Layout.Row = 4; - edtSmoothRadius.Layout.Column = 2; - - [lblUncertainBlend, edtUncertainBlend] = labkit.ui.view.form(analysisGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Uncertain blend (%):', ... - 'value', 5, ... - 'limits', [0 100], ... - 'step', 1)); - lblUncertainBlend.Layout.Row = 5; - lblUncertainBlend.Layout.Column = 1; - edtUncertainBlend.Layout.Row = 5; - edtUncertainBlend.Layout.Column = 2; - - btnRun = uibutton(analysisGrid, 'Text', 'Run focus stack', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onRunFocusStack); - btnRun.Layout.Row = 6; - btnRun.Layout.Column = [1 2]; - - exportPanel = labkit.ui.view.section(layFA, 'Export', 3, [3 1], ... - struct('rowHeight', {{'fit', 'fit', 'fit'}})); - exportGrid = exportPanel.grid; - - btnExportFused = uibutton(exportGrid, 'Text', 'Export fused PNG', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onExportFused); - btnExportFused.Layout.Row = 1; - btnExportMap = uibutton(exportGrid, 'Text', 'Export focus map PNG', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onExportFocusMap); - btnExportMap.Layout.Row = 2; - btnExportSummary = uibutton(exportGrid, 'Text', 'Export summary CSV', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onExportSummary); - btnExportSummary.Layout.Row = 3; - - labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 4, { ... - '1. Load a folder or select one or more image files from the same microscope field of view.', ... - '2. Use file selection when a folder contains bad frames that should be excluded.', ... - '3. Start with Balanced. Use Crisp for fine texture, Smooth for visible seams, Noisy for grainy images.', ... - '4. Detail scale controls feature size; Blend radius controls seam softness; Uncertain blend softens low-texture areas.'}); - - resultTable = uitable(laySR, ... - 'ColumnName', {'Metric', 'Value'}, ... - 'Data', focus_stack.view.initialResultTable()); - resultTable.Layout.Row = 1; - - txtDetails = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(txtDetails, laySR, 2); - txtDetails.Value = {'Load a focus image folder or select image files to begin.'}; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - txtLog = logUi.textArea; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('Focus stack debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - resetPreviewAxes(); - refreshSummary(); - + fig = focus_stack.run(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenFolder(~, ~) - folder = uigetdir(pwd, 'Select focus image folder'); - if isequal(folder, 0) - addLog('Image folder selection cancelled.'); - return; - end - loadImageFolder(string(folder)); - end - - function onOpenFiles(~, ~) - [files, folder] = uigetfile(focus_stack.io.imageDialogFilter(), ... - 'Select focus image files', pwd, 'MultiSelect', 'on'); - if isequal(files, 0) - addLog('Image file selection cancelled.'); - return; - end - - try - paths = focus_stack.io.selectedImagePaths(files, folder); - catch ME - showError('Could not select focus images', ME.message); - return; - end - loadImagePaths(paths, string(folder), ... - sprintf('Selected image files from %s', char(folder)), ... - sprintf('Loaded %d selected image file(s).', numel(paths))); - end - - function loadImageFolder(folder) - try - paths = focus_stack.io.findImages(folder); - catch ME - showError('Could not load focus stack', ME.message); - return; - end - loadImagePaths(paths, folder, char(folder), ... - sprintf('Loaded %d image(s) from folder.', numel(paths))); - end - - function loadImagePaths(paths, sourceFolder, sourceDescription, logMessage) - try - images = focus_stack.io.readImages(paths); - catch ME - showError('Could not load focus stack', ME.message); - return; - end - - sourceDescription = string(sourceDescription); - S.paths = paths; - S.images = images; - S.alignedImages = {}; - S.registrationLines = {}; - S.result = focus_stack.state.emptyResult(); - S.folder = string(sourceFolder); - - txtFolder.Value = char(sourceDescription); - lbImages.Items = focus_stack.view.displayImageNames(paths); - if ~isempty(lbImages.Items) - lbImages.Value = lbImages.Items{1}; - end - addLog(logMessage); - refreshPreview(); - refreshSummary(); - end - - function onRunFocusStack(~, ~) - if numel(S.images) < 2 - showError('Not enough images', 'Load at least two images before running focus stacking.'); - return; - end - - opts = currentFusionOptions(); - registerStack = chkRegister.Value; - busyOpts = struct(); - busyOpts.title = 'Focus stacking'; - busyOpts.message = 'Fusing selected microscope images...'; - busyOpts.controls = focusStackBusyControls(); - try - payload = labkit.ui.app.runBusy(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) - addLog(S.registrationLines{k}); - end - refreshPreview(); - refreshSummary(); - end - - function opts = currentFusionOptions() - opts = struct(); - opts.focusWindow = round(edtFocusWindow.Value); - opts.smoothRadius = round(edtSmoothRadius.Value); - opts.minConfidence = edtUncertainBlend.Value / 100; - end - - function onFusionPresetChanged(~, ~) - settings = focus_stack.state.fusionPresetSettings(ddFusionPreset.Value); - edtFocusWindow.Value = settings.focusWindow; - edtSmoothRadius.Value = settings.smoothRadius; - edtUncertainBlend.Value = settings.minConfidencePercent; - addLog(sprintf('Fusion preset set to %s.', ddFusionPreset.Value)); - end - - function payload = runFocusStackComputation(opts, registerStack) - imagesForFusion = S.images; - registrationLines = {}; - if registerStack - [imagesForFusion, registrationLines] = focus_stack.ops.alignImages(S.images); - end - - payload = struct(); - payload.imagesForFusion = imagesForFusion; - payload.registrationLines = registrationLines; - payload.result = focus_stack.ops.computeFocusStack(imagesForFusion, opts); - end - - function controls = focusStackBusyControls() - controls = {btnOpenFolder, btnOpenFiles, lbImages, ddFusionPreset, chkRegister, ... - edtFocusWindow, edtSmoothRadius, edtUncertainBlend, btnRun, ... - btnExportFused, btnExportMap, btnExportSummary}; - end - - function onExportFused(~, ~) - if ~S.result.ok - showError('No fused image', 'Run focus stack before exporting the fused PNG.'); - return; - end - filepath = chooseSavePath('Export fused PNG', 'focus_stack_fused.png'); - if filepath == "" - addLog('Export fused PNG cancelled.'); - return; - end - try - imwrite(S.result.fused, filepath); - catch ME - showError('Could not export fused PNG', ME.message); - return; - end - addLog(sprintf('Exported fused PNG: %s', filepath)); - end - - function onExportFocusMap(~, ~) - if ~S.result.ok - showError('No focus map', 'Run focus stack before exporting the focus map PNG.'); - return; - end - filepath = chooseSavePath('Export focus map PNG', 'focus_stack_map.png'); - if filepath == "" - addLog('Export focus map PNG cancelled.'); - return; - end - try - imwrite(focus_stack.view.focusIndexRgb(S.result.focusIndex, S.result.inputCount), filepath); - catch ME - showError('Could not export focus map PNG', ME.message); - return; - end - addLog(sprintf('Exported focus map PNG: %s', filepath)); - end - - function onExportSummary(~, ~) - if ~S.result.ok - showError('No summary', 'Run focus stack before exporting the summary CSV.'); - return; - end - filepath = chooseSavePath('Export summary CSV', 'focus_stack_summary.csv'); - if filepath == "" - addLog('Export summary CSV cancelled.'); - return; - end - try - T = focus_stack.export.buildSummaryTable(S.result, S.paths); - writetable(T, filepath); - catch ME - showError('Could not export summary CSV', ME.message); - return; - end - addLog(sprintf('Exported summary CSV: %s', filepath)); - end - - function filepath = chooseSavePath(titleText, defaultName) - defaultPath = fullfile(defaultSaveFolder(), defaultName); - [fn, fp] = uiputfile({'*.png;*.csv', 'Export files'}, titleText, defaultPath); - if isequal(fn, 0) - filepath = ""; - else - filepath = string(fullfile(fp, fn)); - end - end - - function folder = defaultSaveFolder() - folder = char(S.folder); - if isempty(folder) || exist(folder, 'dir') ~= 7 - folder = pwd; - end - end - - function refreshPreview() - if S.result.ok - labkit.ui.view.draw(ui.topAxes, 'image', S.result.fused, ... - 'Fused all-in-focus image'); - labkit.ui.view.draw(ui.bottomAxes, 'image', ... - focus_stack.view.focusIndexRgb(S.result.focusIndex, S.result.inputCount), ... - 'Focus-depth index map'); - elseif ~isempty(S.images) - labkit.ui.view.draw(ui.topAxes, 'image', focus_stack.view.previewImage(S.images{1}), ... - 'First source image'); - labkit.ui.view.draw(ui.bottomAxes, 'reset', 'Focus-depth index map', true); - else - resetPreviewAxes(); - end - updateControls(); - end - - function refreshSummary() - txtStackStatus.Value = sprintf('Images: %d', numel(S.images)); - if S.result.ok - resultTable.Data = focus_stack.view.resultTableData(S.result); - txtDetails.Value = focus_stack.view.details(S.result, S.paths, S.registrationLines); - elseif numel(S.images) >= 2 - resultTable.Data = focus_stack.view.initialResultTable(); - txtDetails.Value = { ... - sprintf('Loaded images: %d', numel(S.images)), ... - 'Run focus stack to compute the fused image and focus-depth map.'}; - elseif ~isempty(S.images) - resultTable.Data = focus_stack.view.initialResultTable(); - txtDetails.Value = { ... - sprintf('Loaded images: %d', numel(S.images)), ... - 'Load at least two images before running focus stack.'}; - else - resultTable.Data = focus_stack.view.initialResultTable(); - txtDetails.Value = {'Load a focus image folder or select image files to begin.'}; - end - updateControls(); - end - - function updateControls() - hasStack = numel(S.images) >= 2; - hasResult = S.result.ok; - btnRun.Enable = focus_stack.view.ternary(hasStack, 'on', 'off'); - btnExportFused.Enable = focus_stack.view.ternary(hasResult, 'on', 'off'); - btnExportMap.Enable = focus_stack.view.ternary(hasResult, 'on', 'off'); - btnExportSummary.Enable = focus_stack.view.ternary(hasResult, 'on', 'off'); - end - - function resetPreviewAxes() - labkit.ui.view.draw(ui.topAxes, 'reset', 'Fused all-in-focus image', true); - labkit.ui.view.draw(ui.bottomAxes, 'reset', 'Focus-depth index map', true); - end - - function addLog(message) - labkit.ui.view.update(txtLog, 'appendLog', message); - debugLog.append(message); - end - - function showError(titleText, message) - addLog(sprintf('%s: %s', titleText, message)); - uialert(fig, message, titleText); - end end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ui/buildSpec.m b/apps/image_measurement/image_enhance/+image_enhance/+ui/buildSpec.m new file mode 100644 index 0000000..c98dedb --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+ui/buildSpec.m @@ -0,0 +1,112 @@ +% Expected caller: labkit_ImageEnhance_app. Inputs are tool labels, initial +% export folder text, and callback handles. Output is a data-only UI 2.0 +% workbench spec for the Image Enhance app. +function spec = buildSpec(stepKinds, outputFolder, callbacks) + + spec = labkit.ui.spec.app('imageEnhanceApp', 'Paper Image Enhance', ... + 'position', [80 60 1460 860], ... + 'leftWidth', 470, ... + 'controlTabs', { ... + labkit.ui.spec.tab('libraryExport', 'Library + Export', { ... + labkit.ui.spec.section('librarySection', 'Library', { ... + labkit.ui.spec.pathPanel('sourceImages', 'Source images', ... + 'mode', 'multiFile', ... + 'selectionMode', 'single', ... + 'filters', image_enhance.io.imageDialogFilter(), ... + 'status', 'No images loaded', ... + 'emptyText', 'No images loaded', ... + 'height', 'flex', ... + 'onChoose', callbackValue(callbacks, 'sourceImagesChosen'), ... + 'onSelectionChange', callbackValue(callbacks, 'imageSelectionChanged'), ... + 'onClear', callbackValue(callbacks, 'clearImages')), ... + labkit.ui.spec.field('imageStatus', 'Status', ... + 'kind', 'readonly', ... + 'value', 'Images: 0')}, ... + 'height', 250), ... + labkit.ui.spec.section('exportSection', 'Batch Export', { ... + labkit.ui.spec.field('outputFolder', 'Output folder', ... + 'kind', 'readonly', ... + 'value', outputFolder), ... + labkit.ui.spec.field('exportFormat', 'Format', ... + 'kind', 'dropdown', ... + 'items', {'PNG', 'TIFF', 'JPEG'}, ... + 'value', 'PNG'), ... + labkit.ui.spec.actionGroup('exportActions', { ... + labkit.ui.spec.action('chooseOutputFolder', 'Choose folder', ... + callbackValue(callbacks, 'chooseOutputFolder')), ... + labkit.ui.spec.action('exportImages', 'Export enhanced images', ... + callbackValue(callbacks, 'exportImages'), ... + 'enabled', false)})}, ... + 'height', 185), ... + labkit.ui.spec.section('exportDetailsSection', 'Export Details', { ... + labkit.ui.spec.statusPanel('exportDetails', 'Export Details', ... + 'value', image_enhance.view.detailLines( ... + repmat(image_enhance.state.emptyItem(), 0, 1), 1, ... + repmat(image_enhance.state.emptyStep(), 0, 1), []), ... + 'height', 'flex')}, ... + 'height', 150)}), ... + labkit.ui.spec.tab('toolsHistory', 'Tools + History', { ... + labkit.ui.spec.section('toolSection', 'Toolbox', { ... + labkit.ui.spec.field('toolKind', 'Tool', ... + 'kind', 'dropdown', ... + 'items', stepKinds, ... + 'value', stepKinds{1}, ... + 'onChange', callbackValue(callbacks, 'toolChanged')), ... + labkit.ui.spec.field('toolAmount', 'Brightness (%)', ... + 'kind', 'spinner', ... + 'value', 0, ... + 'limits', [-100 100], ... + 'step', 1, ... + 'onChange', callbackValue(callbacks, 'toolSettingChanged')), ... + labkit.ui.spec.field('toolSecondary', 'Contrast (%)', ... + 'kind', 'spinner', ... + 'value', 15, ... + 'limits', [-100 100], ... + 'step', 1, ... + 'onChange', callbackValue(callbacks, 'toolSettingChanged')), ... + labkit.ui.spec.field('toolStatus', 'Status', ... + 'kind', 'readonly', ... + 'value', 'Select an image, choose a tool, then apply it to history.'), ... + labkit.ui.spec.action('applyTool', 'Apply tool', ... + callbackValue(callbacks, 'applyTool'), ... + 'enabled', false)}, ... + 'height', 390), ... + labkit.ui.spec.section('historySection', 'Step History', { ... + labkit.ui.spec.actionGroup('historyActions', { ... + labkit.ui.spec.action('undoHistory', 'Undo history', ... + callbackValue(callbacks, 'undoHistory'), ... + 'enabled', false), ... + labkit.ui.spec.action('resetHistory', 'Reset history', ... + callbackValue(callbacks, 'resetHistory'), ... + 'enabled', false)}), ... + labkit.ui.spec.resultTable('historyTable', 'Step History', ... + 'columns', {'#', 'Step', 'Settings'}, ... + 'data', image_enhance.view.historyTableData( ... + repmat(image_enhance.state.emptyStep(), 0, 1))), ... + labkit.ui.spec.field('historyStatus', 'Status', ... + 'kind', 'readonly', ... + 'value', 'History steps: 0')}, ... + 'height', 245), ... + labkit.ui.spec.section('currentImageSection', 'Current Image', { ... + labkit.ui.spec.resultTable('metricsTable', 'Current Image', ... + 'columns', {'Metric', 'Value'}, ... + 'data', image_enhance.view.resultTableData([], [], 0))}, ... + 'height', 125)}), ... + labkit.ui.spec.tab('log', 'Log', { ... + labkit.ui.spec.section('logSection', 'Log', { ... + labkit.ui.spec.logPanel('logPanel', 'Log')}, ... + 'height', 'flex')})}, ... + 'workspace', labkit.ui.spec.workspace('workspace', 'Preview', { ... + labkit.ui.spec.previewArea('preview', 'Preview', ... + 'layout', 'single', ... + 'axisTitles', {'Enhanced Preview'}, ... + 'viewModes', {'Enhanced', 'Original', 'Before | After'}, ... + 'onModeChange', callbackValue(callbacks, 'previewModeChanged'))})); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ui/createEditorUi.m b/apps/image_measurement/image_enhance/+image_enhance/+ui/createEditorUi.m deleted file mode 100644 index b624695..0000000 --- a/apps/image_measurement/image_enhance/+image_enhance/+ui/createEditorUi.m +++ /dev/null @@ -1,195 +0,0 @@ -% Expected caller: labkit_ImageEnhance_app. Inputs are the tool labels, initial -% export folder, and app callback handles. Output is a struct of UI component -% handles for the image-enhancement editor shell. -function uih = createEditorUi(stepKinds, outputFolder, callbacks) - - workbenchOpts = struct( ... - 'rightTitle', 'Preview', ... - 'rightGridSize', [1 1], ... - 'rightRowHeight', {{'1x'}}); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('libraryExport', 'Library + Export', [3 1], ... - {250, 185, 150}), ... - labkit.ui.app.tab('toolsHistory', 'Tools + History', [4 1], ... - {90, 300, 245, 125}), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Paper Image Enhance', ... - 'position', [80 60 1460 860], ... - 'leftWidth', 470, ... - 'options', workbenchOpts)); - - uih = struct(); - uih.fig = ui.fig; - uih.previewAxes = uiaxes(ui.rightGrid); - title(uih.previewAxes, 'Enhanced Preview'); - labkit.ui.view.draw(uih.previewAxes, 'popout'); - - uih = buildLibrarySection(uih, ui.libraryExportGrid, callbacks, 1); - uih = buildExportSection(uih, ui.libraryExportGrid, outputFolder, callbacks, 2); - uih = buildExportDetailsSection(uih, ui.libraryExportGrid, 3); - uih = buildToolsSection(uih, ui.toolsHistoryGrid, stepKinds, callbacks, 1, 2); - uih = buildHistorySection(uih, ui.toolsHistoryGrid, 3); - uih = buildMetricsSection(uih, ui.toolsHistoryGrid, 4); - - logUi = labkit.ui.view.panel(ui.logGrid, 'log', 1, {'Ready.'}); - uih.txtLog = logUi.textArea; -end - -function uih = buildLibrarySection(uih, parentGrid, callbacks, row) - panel = labkit.ui.view.section(parentGrid, 'Library', row, [4 2], ... - struct('rowHeight', {{'fit', 'fit', 125, 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - grid = panel.grid; - - uih.btnOpenFiles = uibutton(grid, 'Text', 'Open image files', ... - 'ButtonPushedFcn', callbacks.openFiles); - place(uih.btnOpenFiles, 1, 1); - uih.btnClearImages = uibutton(grid, 'Text', 'Clear images', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.clearImages); - place(uih.btnClearImages, 1, 2); - - uih.txtImageSource = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', 'No images loaded')); - place(uih.txtImageSource, 2, [1 2]); - - uih.lbImages = uilistbox(grid, ... - 'Items', {'No images loaded'}, ... - 'ValueChangedFcn', callbacks.imageSelectionChanged); - place(uih.lbImages, 3, [1 2]); - - uih.txtImageStatus = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', 'Images: 0')); - place(uih.txtImageStatus, 4, [1 2]); -end - -function uih = buildToolsSection(uih, parentGrid, stepKinds, callbacks, previewRow, toolboxRow) - previewPanel = labkit.ui.view.section(parentGrid, 'Preview', previewRow, [2 2], ... - struct('rowHeight', {{'fit', 'fit'}}, ... - 'columnWidth', {{135, '1x'}})); - [lblPreviewMode, uih.ddPreviewMode] = labkit.ui.view.form(previewPanel.grid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Mode:', ... - 'items', {{'Enhanced', 'Original', 'Before | After'}}, ... - 'value', 'Enhanced', ... - 'callback', callbacks.previewModeChanged)); - place(lblPreviewMode, 1, 1); - place(uih.ddPreviewMode, 1, 2); - uih.txtToolStatus = labkit.ui.view.form(previewPanel.grid, struct( ... - 'kind', 'readonly', ... - 'value', 'Select an image, choose a tool, then apply it to history.')); - place(uih.txtToolStatus, 2, [1 2]); - - toolPanel = labkit.ui.view.section(parentGrid, 'Toolbox', toolboxRow, [6 2], ... - struct('rowHeight', {{145, 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{135, '1x'}})); - grid = toolPanel.grid; - uih.lbTools = uilistbox(grid, ... - 'Items', stepKinds, ... - 'Value', stepKinds{1}, ... - 'ValueChangedFcn', callbacks.toolChanged); - place(uih.lbTools, 1, [1 2]); - - [uih.lblAmount, uih.edtAmount] = labkit.ui.view.form(grid, struct( ... - 'kind', 'spinner', ... - 'label', 'Brightness (%):', ... - 'value', 0, ... - 'limits', [-100 100], ... - 'step', 1)); - place(uih.lblAmount, 2, 1); - place(uih.edtAmount, 2, 2); - uih.edtAmount.ValueChangedFcn = callbacks.toolSettingChanged; - - [uih.lblSecondary, uih.edtSecondary] = labkit.ui.view.form(grid, struct( ... - 'kind', 'spinner', ... - 'label', 'Contrast (%):', ... - 'value', 15, ... - 'limits', [-100 100], ... - 'step', 1)); - place(uih.lblSecondary, 3, 1); - place(uih.edtSecondary, 3, 2); - uih.edtSecondary.ValueChangedFcn = callbacks.toolSettingChanged; - - uih.btnApplyTool = uibutton(grid, 'Text', 'Apply tool', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.applyTool); - place(uih.btnApplyTool, 4, [1 2]); - uih.btnUndoHistory = uibutton(grid, 'Text', 'Undo history', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.undoHistory); - place(uih.btnUndoHistory, 5, [1 2]); - uih.btnResetHistory = uibutton(grid, 'Text', 'Reset history', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.resetHistory); - place(uih.btnResetHistory, 6, [1 2]); -end - -function uih = buildHistorySection(uih, parentGrid, row) - panel = labkit.ui.view.section(parentGrid, 'Step History', row, [2 1], ... - struct('rowHeight', {{200, 'fit'}})); - uih.historyTable = uitable(panel.grid, ... - 'ColumnName', {'#', 'Step', 'Settings'}, ... - 'Data', image_enhance.view.historyTableData(repmat(image_enhance.state.emptyStep(), 0, 1))); - place(uih.historyTable, 1, 1); - uih.txtHistoryStatus = labkit.ui.view.form(panel.grid, struct( ... - 'kind', 'readonly', ... - 'value', 'History steps: 0')); - place(uih.txtHistoryStatus, 2, 1); - -end - -function uih = buildMetricsSection(uih, parentGrid, row) - panel = labkit.ui.view.section(parentGrid, 'Current Image', row, [1 1], ... - struct('rowHeight', {{95}})); - uih.resultTable = uitable(panel.grid, ... - 'ColumnName', {'Metric', 'Value'}, ... - 'Data', image_enhance.view.resultTableData([], [], 0)); - place(uih.resultTable, 1, 1); -end - -function uih = buildExportSection(uih, parentGrid, outputFolder, callbacks, row) - panel = labkit.ui.view.section(parentGrid, 'Batch Export', row, [3 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{135, '1x'}})); - grid = panel.grid; - - uih.btnChooseOutput = uibutton(grid, 'Text', 'Choose folder', ... - 'ButtonPushedFcn', callbacks.chooseOutputFolder); - place(uih.btnChooseOutput, 1, 1); - uih.txtOutputFolder = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', outputFolder)); - place(uih.txtOutputFolder, 1, 2); - - [lblFormat, uih.ddFormat] = labkit.ui.view.form(grid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Format:', ... - 'items', {{'PNG', 'TIFF', 'JPEG'}}, ... - 'value', 'PNG')); - place(lblFormat, 2, 1); - place(uih.ddFormat, 2, 2); - - uih.btnExport = uibutton(grid, 'Text', 'Export enhanced images', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.exportImages); - place(uih.btnExport, 3, [1 2]); -end - -function uih = buildExportDetailsSection(uih, parentGrid, row) - panel = labkit.ui.view.section(parentGrid, 'Export Details', row, [1 1], ... - struct('rowHeight', {{105}})); - uih.txtDetails = uitextarea(panel.grid, 'Editable', 'off'); - place(uih.txtDetails, 1, 1); - uih.txtDetails.Value = image_enhance.view.detailLines( ... - repmat(image_enhance.state.emptyItem(), 0, 1), 1, ... - repmat(image_enhance.state.emptyStep(), 0, 1), []); -end - -function place(component, row, column) - component.Layout.Row = row; - component.Layout.Column = column; -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+view/ternary.m b/apps/image_measurement/image_enhance/+image_enhance/+view/ternary.m deleted file mode 100644 index d0f73e3..0000000 --- a/apps/image_measurement/image_enhance/+image_enhance/+view/ternary.m +++ /dev/null @@ -1,10 +0,0 @@ -% Expected caller: labkit_ImageEnhance_app UI state updates. Return trueValue -% when condition is true, otherwise falseValue. -function value = ternary(condition, trueValue, falseValue) - - if condition - value = trueValue; - else - value = falseValue; - end -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/run.m b/apps/image_measurement/image_enhance/+image_enhance/run.m new file mode 100644 index 0000000..60cce86 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/run.m @@ -0,0 +1,388 @@ +% Expected caller: labkit_ImageEnhance_app. Input is the debug context +% prepared by the public launcher. Output is the app figure. Side effects are +% GUI creation, user-driven image loading, image export, and debug trace attachment. +function fig = run(debugLog) +%RUN Build and run the Image Enhance app body. + + S = struct(); + S.items = repmat(image_enhance.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.outputFolder = string(pwd); + S.lastExport = []; + S.pendingDirty = false; + + stepKinds = {'Brightness/contrast', 'Local contrast', 'Sharpen', ... + 'Hue/saturation', 'White balance'}; + callbacks = struct( ... + 'sourceImagesChosen', @onSourceImagesChosen, ... + 'clearImages', @onClearImages, ... + 'imageSelectionChanged', @onImageSelectionChanged, ... + 'previewModeChanged', @onPreviewModeChanged, ... + 'toolChanged', @onToolChanged, ... + 'toolSettingChanged', @onToolSettingChanged, ... + 'applyTool', @onApplyTool, ... + 'undoHistory', @onUndoHistory, ... + 'resetHistory', @onResetHistory, ... + 'chooseOutputFolder', @onChooseOutputFolder, ... + 'exportImages', @onExportImages); + spec = image_enhance.ui.buildSpec(stepKinds, char(S.outputFolder), callbacks); + ui = labkit.ui.app.create(spec, 'debug', debugLog); + fig = ui.figure; + if debugLog.enabled + debugLog.trace('Image enhance debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + resetPreviewAxes(); + updateToolControls(true); + refreshAll(); + + function onSourceImagesChosen(~, event) + try + S.items = image_enhance.io.readImages(event.paths); + catch ME + showError('Could not load images', ME.message); + refreshAll(); + return; + end + + S.currentIndex = 1; + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.outputFolder = string(fileparts(event.paths{1})); + S.lastExport = []; + addLog(sprintf('Loaded %d image(s).', numel(S.items))); + refreshAll(); + end + + function onClearImages(~, ~) + S.items = repmat(image_enhance.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Cleared loaded images and enhancement history.'); + refreshAll(); + end + + function onImageSelectionChanged(~, event) + if isempty(S.items) || isempty(event.value) + return; + end + selectedPath = string(event.value); + idx = find(string({S.items.path}) == selectedPath, 1); + if isempty(idx) + return; + end + S.currentIndex = idx; + refreshSelection(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + end + + function onPreviewModeChanged(~, ~) + refreshPreview(); + end + + function onToolChanged(~, ~) + updateToolControls(true); + S.pendingDirty = true; + S.lastExport = []; + refreshPreview(); + refreshToolStatus(); + end + + function onToolSettingChanged(~, ~) + updateToolControls(false); + S.pendingDirty = true; + S.lastExport = []; + refreshPreview(); + refreshToolStatus(); + end + + function onApplyTool(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before applying enhancement tools.'); + return; + end + step = currentToolStep(); + S.steps(end + 1, 1) = step; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Applied tool: %s', char(step.label))); + refreshAll(); + end + + function onUndoHistory(~, ~) + if isempty(S.steps) + return; + end + removed = S.steps(end); + S.steps(end) = []; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Undid history step: %s', char(removed.label))); + refreshAll(); + end + + function onResetHistory(~, ~) + if isempty(S.steps) + return; + end + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Reset enhancement history.'); + refreshAll(); + end + + function onChooseOutputFolder(~, ~) + folder = uigetdir(char(S.outputFolder), 'Select image enhancement export folder'); + if isequal(folder, 0) + addLog('Export folder selection cancelled.'); + return; + end + S.outputFolder = string(folder); + refreshExportControls(); + refreshDetails(); + end + + function onExportImages(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before exporting enhanced outputs.'); + return; + end + opts = struct(); + opts.outputFolder = S.outputFolder; + opts.format = labkit.ui.view.getValue(ui, 'exportFormat'); + busyOpts = struct(); + busyOpts.title = 'Export enhanced images'; + busyOpts.message = 'Writing enhanced image outputs...'; + busyOpts.controls = exportBusyControls(); + try + S.lastExport = labkit.ui.app.runBusy(fig, ... + @() image_enhance.export.writeOutputs(S.items, S.steps, opts), busyOpts); + catch ME + showError('Export failed', ME.message); + return; + end + statuses = string({S.lastExport.results.status}); + addLog(sprintf('Exported %d image(s), %d failed. Manifest: %s', ... + sum(statuses == "saved"), sum(statuses == "failed"), ... + char(S.lastExport.manifestPath))); + refreshDetails(); + end + + function controls = exportBusyControls() + controls = { ... + ui.controls.sourceImages.chooseButton, ... + ui.controls.sourceImages.clearButton, ... + ui.controls.sourceImages.listbox, ... + ui.controls.preview.viewModeDropDown, ... + ui.controls.toolKind.handle, ... + ui.controls.toolAmount.handle, ... + ui.controls.toolSecondary.handle, ... + ui.controls.applyTool.button, ... + ui.controls.undoHistory.button, ... + ui.controls.resetHistory.button, ... + ui.controls.chooseOutputFolder.button, ... + ui.controls.exportFormat.handle, ... + ui.controls.exportImages.button}; + end + + function refreshAll() + refreshSourceLibrary(); + updateToolControls(false); + refreshControls(); + refreshSelection(); + refreshHistory(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + refreshToolStatus(); + refreshExportControls(); + end + + function refreshSourceLibrary() + if isempty(S.items) + labkit.ui.view.setValue(ui, 'sourceImages', {}); + labkit.ui.view.setValue(ui, 'imageStatus', 'Images: 0'); + return; + end + paths = cellstr(string({S.items.path})); + labkit.ui.view.setValue(ui, 'sourceImages', paths); + labkit.ui.view.setValue(ui, 'imageStatus', sprintf( ... + 'Images: %d | history steps: %d', numel(S.items), numel(S.steps))); + end + + function refreshSelection() + if isempty(S.items) + return; + end + paths = cellstr(string({S.items.path})); + labkit.ui.view.setListSelection(ui, 'sourceImages', paths, ... + paths{currentSelectionIndex()}, struct()); + end + + function refreshControls() + hasImages = ~isempty(S.items); + hasSteps = ~isempty(S.steps); + ui.controls.sourceImages.clearButton.Enable = onOff(hasImages); + ui.controls.sourceImages.listbox.Enable = onOff(hasImages); + labkit.ui.view.setEnabled(ui, 'applyTool', hasImages); + labkit.ui.view.setEnabled(ui, 'undoHistory', hasSteps); + labkit.ui.view.setEnabled(ui, 'resetHistory', hasSteps); + labkit.ui.view.setEnabled(ui, 'exportImages', hasImages); + end + + function refreshExportControls() + labkit.ui.view.setValue(ui, 'outputFolder', char(S.outputFolder)); + end + + function refreshPreview() + if isempty(S.items) + resetPreviewAxes(); + return; + end + original = S.items(currentSelectionIndex()).image; + processed = currentProcessedImages(S.pendingDirty); + enhanced = processed{currentSelectionIndex()}; + switch currentPreviewMode() + case 'Original' + labkit.ui.view.drawImage(ui, 'preview', original, ... + 'title', 'Original Preview'); + case 'Before | After' + labkit.ui.view.drawImage(ui, 'preview', ... + image_enhance.view.beforeAfterImage(original, enhanced), ... + 'title', 'Before | After'); + otherwise + labkit.ui.view.drawImage(ui, 'preview', enhanced, ... + 'title', 'Enhanced Preview'); + end + end + + function refreshMetrics() + if isempty(S.items) + ui.controls.metricsTable.table.Data = ... + image_enhance.view.resultTableData([], [], 0); + return; + end + processed = currentProcessedImages(false); + ui.controls.metricsTable.table.Data = image_enhance.view.resultTableData( ... + S.items(currentSelectionIndex()), ... + processed{currentSelectionIndex()}, numel(S.steps)); + end + + function refreshHistory() + ui.controls.historyTable.table.Data = image_enhance.view.historyTableData(S.steps); + labkit.ui.view.setValue(ui, 'historyStatus', ... + sprintf('History steps: %d', numel(S.steps))); + end + + function refreshDetails() + labkit.ui.view.setValue(ui, 'exportDetails', image_enhance.view.detailLines( ... + S.items, max(currentSelectionIndex(), 1), S.steps, S.lastExport)); + end + + function refreshToolStatus() + if isempty(S.items) + labkit.ui.view.setValue(ui, 'toolStatus', ... + 'Select an image, choose a tool, then apply it to history.'); + return; + end + step = currentToolStep(); + if S.pendingDirty + prefix = 'Previewing: '; + else + prefix = 'Ready: '; + end + labkit.ui.view.setValue(ui, 'toolStatus', [prefix char(step.label)]); + end + + function processed = currentProcessedImages(includePending) + images = cell(numel(S.items), 1); + for k = 1:numel(S.items) + images{k} = S.items(k).image; + end + steps = S.steps; + if includePending + steps(end + 1, 1) = currentToolStep(); + end + processed = image_enhance.ops.applyPipeline(images, steps); + end + + function step = currentToolStep() + step = image_enhance.ops.makeStep( ... + labkit.ui.view.getValue(ui, 'toolKind'), ... + labkit.ui.view.getValue(ui, 'toolAmount'), ... + labkit.ui.view.getValue(ui, 'toolSecondary'), 0); + end + + function updateToolControls(resetToDefaults) + values = image_enhance.ops.defaultStepValues( ... + labkit.ui.view.getValue(ui, 'toolKind')); + amountHandle = ui.controls.toolAmount.handle; + secondaryHandle = ui.controls.toolSecondary.handle; + ui.controls.toolAmount.label.Text = char(values.amountLabel); + ui.controls.toolSecondary.label.Text = char(values.secondaryLabel); + amountHandle.Limits = values.amountLimits; + secondaryHandle.Limits = values.secondaryLimits; + amountHandle.Value = clampValue(amountHandle.Value, values.amountLimits); + secondaryHandle.Value = clampValue(secondaryHandle.Value, values.secondaryLimits); + if resetToDefaults + labkit.ui.view.setValue(ui, 'toolAmount', values.amount); + labkit.ui.view.setValue(ui, 'toolSecondary', values.secondary); + end + end + + function index = currentSelectionIndex() + if isempty(S.items) + index = 0; + return; + end + S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); + index = S.currentIndex; + end + + function mode = currentPreviewMode() + mode = string(labkit.ui.view.getValue(ui, 'preview')); + if strlength(mode) == 0 + mode = "Enhanced"; + end + mode = char(mode); + end + + function resetPreviewAxes() + labkit.ui.view.resetAxes(ui, 'preview', 'Enhanced Preview', true); + end + + function addLog(message) + labkit.ui.view.appendLog(ui, 'logPanel', message); + if debugLog.enabled + debugLog.append(message); + end + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end +end + +function value = clampValue(value, limits) + value = min(max(value, limits(1)), limits(2)); +end + +function text = onOff(value) + if islogical(value) && isscalar(value) + if value + text = 'on'; + else + text = 'off'; + end + else + text = char(string(value)); + end +end diff --git a/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m b/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m index b6d1441..927e597 100644 --- a/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m +++ b/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m @@ -17,366 +17,11 @@ 'labkit_ImageEnhance_app returns at most the app figure handle.'); end - S = struct(); - S.items = repmat(image_enhance.state.emptyItem(), 0, 1); - S.currentIndex = 0; - S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); - S.outputFolder = string(pwd); - S.lastExport = []; - S.pendingDirty = false; - - stepKinds = {'Brightness/contrast', 'Local contrast', 'Sharpen', ... - 'Hue/saturation', 'White balance'}; - - callbacks = struct( ... - 'openFiles', @onOpenFiles, ... - 'clearImages', @onClearImages, ... - 'imageSelectionChanged', @onImageSelectionChanged, ... - 'previewModeChanged', @onPreviewModeChanged, ... - 'toolChanged', @onToolChanged, ... - 'toolSettingChanged', @onToolSettingChanged, ... - 'applyTool', @onApplyTool, ... - 'undoHistory', @onUndoHistory, ... - 'resetHistory', @onResetHistory, ... - 'chooseOutputFolder', @onChooseOutputFolder, ... - 'exportImages', @onExportImages); - uih = image_enhance.ui.createEditorUi(stepKinds, char(S.outputFolder), callbacks); - fig = uih.fig; previewAxes = uih.previewAxes; txtLog = uih.txtLog; - btnOpenFiles = uih.btnOpenFiles; btnClearImages = uih.btnClearImages; - lbImages = uih.lbImages; txtImageSource = uih.txtImageSource; - txtImageStatus = uih.txtImageStatus; ddPreviewMode = uih.ddPreviewMode; - lbTools = uih.lbTools; txtToolStatus = uih.txtToolStatus; - lblAmount = uih.lblAmount; edtAmount = uih.edtAmount; - lblSecondary = uih.lblSecondary; edtSecondary = uih.edtSecondary; - btnApplyTool = uih.btnApplyTool; - btnUndoHistory = uih.btnUndoHistory; btnResetHistory = uih.btnResetHistory; - historyTable = uih.historyTable; txtHistoryStatus = uih.txtHistoryStatus; - resultTable = uih.resultTable; btnChooseOutput = uih.btnChooseOutput; - txtOutputFolder = uih.txtOutputFolder; ddFormat = uih.ddFormat; - btnExport = uih.btnExport; txtDetails = uih.txtDetails; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('Image enhance debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - resetPreviewAxes(); - updateToolControls(false); - refreshAll(); - + fig = image_enhance.run(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenFiles(~, ~) - [files, folder] = uigetfile(image_enhance.io.imageDialogFilter(), ... - 'Select images to enhance', pwd, 'MultiSelect', 'on'); - if isequal(files, 0) - addLog('Image file selection cancelled.'); - return; - end - - try - paths = image_enhance.io.selectedImagePaths(files, folder); - S.items = image_enhance.io.readImages(paths); - catch ME - showError('Could not load images', ME.message); - return; - end - - S.currentIndex = 1; - S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); - S.pendingDirty = false; - S.outputFolder = string(folder); - S.lastExport = []; - txtOutputFolder.Value = char(S.outputFolder); - addLog(sprintf('Loaded %d image(s).', numel(S.items))); - refreshAll(); - end - - function onClearImages(~, ~) - S.items = repmat(image_enhance.state.emptyItem(), 0, 1); - S.currentIndex = 0; - S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); - S.pendingDirty = false; - S.lastExport = []; - addLog('Cleared loaded images and enhancement history.'); - refreshAll(); - end - - function onImageSelectionChanged(~, ~) - if isempty(S.items) - return; - end - - names = image_enhance.view.displayImageNames(S.items); - idx = find(strcmp(names, lbImages.Value), 1); - if isempty(idx) - return; - end - S.currentIndex = idx; - refreshSelection(); - refreshPreview(); - refreshMetrics(); - refreshDetails(); - end - - function onPreviewModeChanged(~, ~) - refreshPreview(); - end - - function onToolChanged(~, ~) - updateToolControls(true); - S.pendingDirty = true; - S.lastExport = []; - refreshPreview(); - refreshToolStatus(); - end - - function onToolSettingChanged(~, ~) - updateToolControls(false); - S.pendingDirty = true; - S.lastExport = []; - refreshPreview(); - refreshToolStatus(); - end - - function onApplyTool(~, ~) - if isempty(S.items) - showError('No images loaded', 'Load images before applying enhancement tools.'); - return; - end - - step = currentToolStep(); - S.steps(end + 1, 1) = step; - S.pendingDirty = false; - S.lastExport = []; - addLog(sprintf('Applied tool: %s', char(step.label))); - refreshAll(); - end - - function onUndoHistory(~, ~) - if isempty(S.steps) - return; - end - removed = S.steps(end); - S.steps(end) = []; - S.pendingDirty = false; - S.lastExport = []; - addLog(sprintf('Undid history step: %s', char(removed.label))); - refreshAll(); - end - - function onResetHistory(~, ~) - if isempty(S.steps) - return; - end - S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); - S.pendingDirty = false; - S.lastExport = []; - addLog('Reset enhancement history.'); - refreshAll(); - end - - function onChooseOutputFolder(~, ~) - folder = uigetdir(char(S.outputFolder), 'Select image enhancement export folder'); - if isequal(folder, 0) - addLog('Export folder selection cancelled.'); - return; - end - S.outputFolder = string(folder); - txtOutputFolder.Value = char(S.outputFolder); - refreshDetails(); - end - - function onExportImages(~, ~) - if isempty(S.items) - showError('No images loaded', 'Load images before exporting enhanced outputs.'); - return; - end - - opts = struct(); - opts.outputFolder = S.outputFolder; - opts.format = ddFormat.Value; - busyOpts = struct(); - busyOpts.title = 'Export enhanced images'; - busyOpts.message = 'Writing enhanced image outputs...'; - busyOpts.controls = exportBusyControls(); - try - S.lastExport = labkit.ui.app.runBusy(fig, ... - @() image_enhance.export.writeOutputs(S.items, S.steps, opts), busyOpts); - catch ME - showError('Export failed', ME.message); - return; - end - - statuses = string({S.lastExport.results.status}); - addLog(sprintf('Exported %d image(s), %d failed. Manifest: %s', ... - sum(statuses == "saved"), sum(statuses == "failed"), ... - char(S.lastExport.manifestPath))); - refreshDetails(); - end - - function controls = exportBusyControls() - controls = {btnOpenFiles, btnClearImages, lbImages, ddPreviewMode, ... - lbTools, edtAmount, edtSecondary, btnApplyTool, ... - btnUndoHistory, btnResetHistory, btnChooseOutput, ddFormat, btnExport}; - end - - function refreshAll() - refreshList(); - updateToolControls(false); - refreshControls(); - refreshSelection(); - refreshHistory(); - refreshPreview(); - refreshMetrics(); - refreshDetails(); - refreshToolStatus(); - end - - function refreshList() - if isempty(S.items) - lbImages.Items = {'No images loaded'}; - lbImages.Value = 'No images loaded'; - txtImageSource.Value = 'No images loaded'; - txtImageStatus.Value = 'Images: 0'; - return; - end - - names = image_enhance.view.displayImageNames(S.items); - S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); - lbImages.Items = names; - lbImages.Value = names{S.currentIndex}; - txtImageStatus.Value = sprintf('Images: %d | history steps: %d', ... - numel(S.items), numel(S.steps)); - - end - - function refreshSelection() - if isempty(S.items) - txtImageSource.Value = 'No images loaded'; - return; - end - - txtImageSource.Value = char(S.items(S.currentIndex).path); - end - - function refreshControls() - hasImages = ~isempty(S.items); - hasSteps = ~isempty(S.steps); - btnClearImages.Enable = image_enhance.view.ternary(hasImages, 'on', 'off'); - btnApplyTool.Enable = image_enhance.view.ternary(hasImages, 'on', 'off'); - btnUndoHistory.Enable = image_enhance.view.ternary(hasSteps, 'on', 'off'); - btnResetHistory.Enable = image_enhance.view.ternary(hasSteps, 'on', 'off'); - btnExport.Enable = image_enhance.view.ternary(hasImages, 'on', 'off'); - end - - function refreshPreview() - if isempty(S.items) - resetPreviewAxes(); - return; - end - - original = S.items(S.currentIndex).image; - processed = currentProcessedImages(S.pendingDirty); - enhanced = processed{S.currentIndex}; - - switch ddPreviewMode.Value - case 'Original' - labkit.ui.view.draw(previewAxes, 'image', original, 'Original Preview'); - case 'Before | After' - labkit.ui.view.draw(previewAxes, 'image', ... - image_enhance.view.beforeAfterImage(original, enhanced), 'Before | After'); - otherwise - labkit.ui.view.draw(previewAxes, 'image', enhanced, 'Enhanced Preview'); - end - end - - function refreshMetrics() - if isempty(S.items) - resultTable.Data = image_enhance.view.resultTableData([], [], 0); - return; - end - - processed = currentProcessedImages(false); - resultTable.Data = image_enhance.view.resultTableData( ... - S.items(S.currentIndex), processed{S.currentIndex}, numel(S.steps)); - end - - function refreshHistory() - historyTable.Data = image_enhance.view.historyTableData(S.steps); - txtHistoryStatus.Value = sprintf('History steps: %d', numel(S.steps)); - end - - function refreshDetails() - txtDetails.Value = image_enhance.view.detailLines( ... - S.items, max(S.currentIndex, 1), S.steps, S.lastExport); - end - - function refreshToolStatus() - if isempty(S.items) - txtToolStatus.Value = 'Select an image, choose a tool, then apply it to history.'; - return; - end - - step = currentToolStep(); - if S.pendingDirty - prefix = 'Previewing: '; - else - prefix = 'Ready: '; - end - txtToolStatus.Value = [prefix char(step.label)]; - end - - function processed = currentProcessedImages(includePending) - images = cell(numel(S.items), 1); - for k = 1:numel(S.items) - images{k} = S.items(k).image; - end - - steps = S.steps; - if includePending - steps(end + 1, 1) = currentToolStep(); - end - processed = image_enhance.ops.applyPipeline(images, steps); - end - - function step = currentToolStep() - step = image_enhance.ops.makeStep(lbTools.Value, ... - edtAmount.Value, edtSecondary.Value, 0); - end - - function updateToolControls(resetToDefaults) - values = image_enhance.ops.defaultStepValues(lbTools.Value); - lblAmount.Text = char(values.amountLabel); - lblSecondary.Text = char(values.secondaryLabel); - edtAmount.Limits = values.amountLimits; - edtSecondary.Limits = values.secondaryLimits; - edtAmount.Value = min(max(edtAmount.Value, edtAmount.Limits(1)), edtAmount.Limits(2)); - edtSecondary.Value = min(max(edtSecondary.Value, edtSecondary.Limits(1)), edtSecondary.Limits(2)); - if resetToDefaults - edtAmount.Value = values.amount; - edtSecondary.Value = values.secondary; - end - end - - function resetPreviewAxes() - labkit.ui.view.draw(previewAxes, 'reset', 'Enhanced Preview', true); - end - - function addLog(message) - labkit.ui.view.update(txtLog, 'appendLog', message); - if debugLog.enabled - debugLog.append(message); - end - end - - function showError(titleText, message) - addLog(sprintf('%s: %s', titleText, message)); - uialert(fig, message, titleText); - end end diff --git a/apps/image_measurement/image_match/+image_match/+io/selectedImagePaths.m b/apps/image_measurement/image_match/+image_match/+io/selectedImagePaths.m deleted file mode 100644 index 2a24efa..0000000 --- a/apps/image_measurement/image_match/+image_match/+io/selectedImagePaths.m +++ /dev/null @@ -1,25 +0,0 @@ -% Expected caller: labkit_ImageMatch_app and image_match IO tests. Inputs -% are uigetfile selected names and folder. Output is a sorted string column of -% absolute file paths. Unsupported extensions raise an app-specific error. -function paths = selectedImagePaths(files, folder) - - folder = string(folder); - if ischar(files) || isstring(files) - files = {char(files)}; - end - - paths = strings(numel(files), 1); - for k = 1:numel(files) - paths(k) = string(fullfile(char(folder), char(files{k}))); - end - paths = sort(paths(:)); - - allowed = image_match.io.supportedImageExtensions(); - for k = 1:numel(paths) - [~, ~, ext] = fileparts(char(paths(k))); - if ~any(strcmpi(string(ext), allowed)) - error('labkit_ImageMatch_app:UnsupportedImageFile', ... - 'Unsupported image file type: %s', char(paths(k))); - end - end -end diff --git a/apps/image_measurement/image_match/+image_match/+ui/buildSpec.m b/apps/image_measurement/image_match/+image_match/+ui/buildSpec.m new file mode 100644 index 0000000..c9081a6 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+ui/buildSpec.m @@ -0,0 +1,126 @@ +% Expected caller: labkit_ImageMatch_app. Inputs are match-method labels, +% initial export folder text, and callback handles. Output is a data-only UI +% 2.0 workbench spec for the Image Match app. +function spec = buildSpec(methods, outputFolder, callbacks) + + spec = labkit.ui.spec.app('imageMatchApp', 'Paper Image Match', ... + 'position', [80 60 1460 860], ... + 'leftWidth', 500, ... + 'controlTabs', { ... + labkit.ui.spec.tab('libraryExport', 'Library + Export', { ... + labkit.ui.spec.section('librarySection', 'Library', { ... + labkit.ui.spec.pathPanel('sourceImages', 'Source images', ... + 'mode', 'multiFile', ... + 'selectionMode', 'single', ... + 'filters', image_match.io.imageDialogFilter(), ... + 'status', 'No images loaded', ... + 'emptyText', 'No images loaded', ... + 'height', 'flex', ... + 'onChoose', callbackValue(callbacks, 'sourceImagesChosen'), ... + 'onSelectionChange', callbackValue(callbacks, 'imageSelectionChanged'), ... + 'onClear', callbackValue(callbacks, 'clearImages')), ... + labkit.ui.spec.field('imageStatus', 'Status', ... + 'kind', 'readonly', ... + 'value', 'Images: 0')}, ... + 'height', 250), ... + labkit.ui.spec.section('exportSection', 'Batch Export', { ... + labkit.ui.spec.field('outputFolder', 'Output folder', ... + 'kind', 'readonly', ... + 'value', outputFolder), ... + labkit.ui.spec.field('exportFormat', 'Format', ... + 'kind', 'dropdown', ... + 'items', {'PNG', 'TIFF', 'JPEG'}, ... + 'value', 'PNG'), ... + labkit.ui.spec.actionGroup('exportActions', { ... + labkit.ui.spec.action('chooseOutputFolder', 'Choose folder', ... + callbackValue(callbacks, 'chooseOutputFolder')), ... + labkit.ui.spec.action('exportImages', 'Export matched images', ... + callbackValue(callbacks, 'exportImages'), ... + 'enabled', false)})}, ... + 'height', 185), ... + labkit.ui.spec.section('exportDetailsSection', 'Export Details', { ... + labkit.ui.spec.statusPanel('exportDetails', 'Export Details', ... + 'value', image_match.view.detailLines( ... + repmat(image_match.state.emptyItem(), 0, 1), 1, ... + repmat(image_match.state.emptyStep(), 0, 1), []), ... + 'height', 'flex')}, ... + 'height', 150)}), ... + labkit.ui.spec.tab('matchHistory', 'Match + History', { ... + labkit.ui.spec.section('matchSection', 'Reference Match', { ... + labkit.ui.spec.field('referenceImage', 'Reference', ... + 'kind', 'dropdown', ... + 'items', {'No reference'}, ... + 'value', 'No reference', ... + 'enabled', false, ... + 'onChange', callbackValue(callbacks, 'matchSettingChanged')), ... + labkit.ui.spec.field('matchMethod', 'Method', ... + 'kind', 'dropdown', ... + 'items', methods, ... + 'value', methods{1}, ... + 'onChange', callbackValue(callbacks, 'matchSettingChanged')), ... + labkit.ui.spec.field('matchStrength', 'Strength (%)', ... + 'kind', 'spinner', ... + 'value', 100, ... + 'limits', [0 100], ... + 'step', 1, ... + 'onChange', callbackValue(callbacks, 'matchSettingChanged')), ... + labkit.ui.spec.field('toneStrength', 'Tone (%)', ... + 'kind', 'spinner', ... + 'value', 100, ... + 'limits', [0 100], ... + 'step', 1, ... + 'onChange', callbackValue(callbacks, 'matchSettingChanged')), ... + labkit.ui.spec.field('colorStrength', 'Color (%)', ... + 'kind', 'spinner', ... + 'value', 100, ... + 'limits', [0 100], ... + 'step', 1, ... + 'onChange', callbackValue(callbacks, 'matchSettingChanged')), ... + labkit.ui.spec.action('applyMatch', 'Apply match', ... + callbackValue(callbacks, 'applyMatch'), ... + 'enabled', false)}, ... + 'height', 265), ... + labkit.ui.spec.section('matchFlowSection', 'Match Flow', { ... + labkit.ui.spec.statusPanel('matchFlow', 'Match Flow', ... + 'value', image_match.view.matchFlowLines(methods{1}), ... + 'height', 'flex')}, ... + 'height', 160), ... + labkit.ui.spec.section('historySection', 'Match History', { ... + labkit.ui.spec.actionGroup('historyActions', { ... + labkit.ui.spec.action('undoHistory', 'Undo history', ... + callbackValue(callbacks, 'undoHistory'), ... + 'enabled', false), ... + labkit.ui.spec.action('resetHistory', 'Reset history', ... + callbackValue(callbacks, 'resetHistory'), ... + 'enabled', false)}), ... + labkit.ui.spec.resultTable('historyTable', 'Match History', ... + 'columns', {'#', 'Step', 'Settings', 'Ref'}, ... + 'data', image_match.view.historyTableData( ... + repmat(image_match.state.emptyStep(), 0, 1))), ... + labkit.ui.spec.field('historyStatus', 'Status', ... + 'kind', 'readonly', ... + 'value', 'History steps: 0')}, ... + 'height', 245), ... + labkit.ui.spec.section('currentImageSection', 'Current Image', { ... + labkit.ui.spec.resultTable('metricsTable', 'Current Image', ... + 'columns', {'Metric', 'Value'}, ... + 'data', image_match.view.resultTableData([], [], 0))}, ... + 'height', 125)}), ... + labkit.ui.spec.tab('log', 'Log', { ... + labkit.ui.spec.section('logSection', 'Log', { ... + labkit.ui.spec.logPanel('logPanel', 'Log')}, ... + 'height', 'flex')})}, ... + 'workspace', labkit.ui.spec.workspace('workspace', 'Preview', { ... + labkit.ui.spec.previewArea('preview', 'Preview', ... + 'layout', 'single', ... + 'axisTitles', {'Matched Preview'}, ... + 'viewModes', {'Matched', 'Original', 'Before | After'}, ... + 'onModeChange', callbackValue(callbacks, 'previewModeChanged'))})); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/image_measurement/image_match/+image_match/+ui/createEditorUi.m b/apps/image_measurement/image_match/+image_match/+ui/createEditorUi.m deleted file mode 100644 index cc0407f..0000000 --- a/apps/image_measurement/image_match/+image_match/+ui/createEditorUi.m +++ /dev/null @@ -1,216 +0,0 @@ -% Expected caller: labkit_ImageMatch_app. Inputs are match method labels, -% initial export folder, and app callback handles. Output is a struct of UI -% component handles for the reference-matching editor shell. -function uih = createEditorUi(methods, outputFolder, callbacks) - - workbenchOpts = struct( ... - 'rightTitle', 'Preview', ... - 'rightGridSize', [1 1], ... - 'rightRowHeight', {{'1x'}}); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('libraryExport', 'Library + Export', [3 1], ... - {250, 185, 150}), ... - labkit.ui.app.tab('matchHistory', 'Match + History', [4 1], ... - {265, 160, 245, 125}), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Paper Image Match', ... - 'position', [80 60 1460 860], ... - 'leftWidth', 500, ... - 'options', workbenchOpts)); - - uih = struct(); - uih.fig = ui.fig; - uih.previewAxes = uiaxes(ui.rightGrid); - title(uih.previewAxes, 'Matched Preview'); - labkit.ui.view.draw(uih.previewAxes, 'popout'); - - uih = buildLibrarySection(uih, ui.libraryExportGrid, callbacks, 1); - uih = buildExportSection(uih, ui.libraryExportGrid, outputFolder, callbacks, 2); - uih = buildExportDetailsSection(uih, ui.libraryExportGrid, 3); - uih = buildMatchSection(uih, ui.matchHistoryGrid, methods, callbacks, 1); - uih = buildMatchFlowSection(uih, ui.matchHistoryGrid, methods, 2); - uih = buildHistorySection(uih, ui.matchHistoryGrid, callbacks, 3); - uih = buildMetricsSection(uih, ui.matchHistoryGrid, 4); - - logUi = labkit.ui.view.panel(ui.logGrid, 'log', 1, {'Ready.'}); - uih.txtLog = logUi.textArea; -end - -function uih = buildLibrarySection(uih, parentGrid, callbacks, row) - panel = labkit.ui.view.section(parentGrid, 'Library', row, [4 2], ... - struct('rowHeight', {{'fit', 'fit', 125, 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - grid = panel.grid; - uih.btnOpenFiles = uibutton(grid, 'Text', 'Open image files', ... - 'ButtonPushedFcn', callbacks.openFiles); - place(uih.btnOpenFiles, 1, 1); - uih.btnClearImages = uibutton(grid, 'Text', 'Clear images', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.clearImages); - place(uih.btnClearImages, 1, 2); - uih.txtImageSource = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', 'No images loaded')); - place(uih.txtImageSource, 2, [1 2]); - uih.lbImages = uilistbox(grid, ... - 'Items', {'No images loaded'}, ... - 'ValueChangedFcn', callbacks.imageSelectionChanged); - place(uih.lbImages, 3, [1 2]); - uih.txtImageStatus = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', 'Images: 0')); - place(uih.txtImageStatus, 4, [1 2]); -end - -function uih = buildMatchSection(uih, parentGrid, methods, callbacks, row) - controlsPanel = labkit.ui.view.section(parentGrid, 'Reference Match', row, [7 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - grid = controlsPanel.grid; - - [lblPreviewMode, uih.ddPreviewMode] = labkit.ui.view.form(grid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Preview:', ... - 'items', {{'Matched', 'Original', 'Before | After'}}, ... - 'value', 'Matched', ... - 'callback', callbacks.previewModeChanged)); - place(lblPreviewMode, 1, 1); - place(uih.ddPreviewMode, 1, 2); - - lblReference = uilabel(grid, 'Text', 'Reference:'); - place(lblReference, 2, 1); - uih.ddReference = uidropdown(grid, ... - 'Items', {'No reference'}, ... - 'Enable', 'off', ... - 'ValueChangedFcn', callbacks.matchSettingChanged); - place(uih.ddReference, 2, 2); - - [lblMethod, uih.ddMethod] = labkit.ui.view.form(grid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Method:', ... - 'items', {methods}, ... - 'value', methods{1}, ... - 'callback', callbacks.matchSettingChanged)); - place(lblMethod, 3, 1); - place(uih.ddMethod, 3, 2); - - [uih.lblStrength, uih.edtStrength] = labkit.ui.view.form(grid, struct( ... - 'kind', 'spinner', ... - 'label', 'Strength (%):', ... - 'value', 100, ... - 'limits', [0 100], ... - 'step', 1)); - place(uih.lblStrength, 4, 1); - place(uih.edtStrength, 4, 2); - uih.edtStrength.ValueChangedFcn = callbacks.matchSettingChanged; - - [uih.lblTone, uih.edtTone] = labkit.ui.view.form(grid, struct( ... - 'kind', 'spinner', ... - 'label', 'Tone (%):', ... - 'value', 100, ... - 'limits', [0 100], ... - 'step', 1)); - place(uih.lblTone, 5, 1); - place(uih.edtTone, 5, 2); - uih.edtTone.ValueChangedFcn = callbacks.matchSettingChanged; - - [uih.lblColor, uih.edtColor] = labkit.ui.view.form(grid, struct( ... - 'kind', 'spinner', ... - 'label', 'Color (%):', ... - 'value', 100, ... - 'limits', [0 100], ... - 'step', 1)); - place(uih.lblColor, 6, 1); - place(uih.edtColor, 6, 2); - uih.edtColor.ValueChangedFcn = callbacks.matchSettingChanged; - - uih.btnApplyMatch = uibutton(grid, 'Text', 'Apply match', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.applyMatch); - place(uih.btnApplyMatch, 7, [1 2]); - -end - -function uih = buildMatchFlowSection(uih, parentGrid, methods, row) - panel = labkit.ui.view.section(parentGrid, 'Match Flow', row, [1 1], ... - struct('rowHeight', {{120}})); - uih.txtMatchFlow = uitextarea(panel.grid, 'Editable', 'off'); - place(uih.txtMatchFlow, 1, 1); - uih.txtMatchFlow.Value = image_match.view.matchFlowLines(methods{1}); -end - -function uih = buildHistorySection(uih, parentGrid, callbacks, row) - panel = labkit.ui.view.section(parentGrid, 'Match History', row, [3 2], ... - struct('rowHeight', {{'fit', 180, 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - grid = panel.grid; - uih.btnUndoHistory = uibutton(grid, 'Text', 'Undo history', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.undoHistory); - uih.btnResetHistory = uibutton(grid, 'Text', 'Reset history', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.resetHistory); - place(uih.btnUndoHistory, 1, 1); - place(uih.btnResetHistory, 1, 2); - uih.historyTable = uitable(grid, ... - 'ColumnName', {'#', 'Step', 'Settings', 'Ref'}, ... - 'Data', image_match.view.historyTableData(repmat(image_match.state.emptyStep(), 0, 1))); - place(uih.historyTable, 2, [1 2]); - uih.txtHistoryStatus = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', 'History steps: 0')); - place(uih.txtHistoryStatus, 3, [1 2]); - -end - -function uih = buildMetricsSection(uih, parentGrid, row) - panel = labkit.ui.view.section(parentGrid, 'Current Image', row, [1 1], ... - struct('rowHeight', {{95}})); - uih.resultTable = uitable(panel.grid, ... - 'ColumnName', {'Metric', 'Value'}, ... - 'Data', image_match.view.resultTableData([], [], 0)); - place(uih.resultTable, 1, 1); -end - -function uih = buildExportSection(uih, parentGrid, outputFolder, callbacks, row) - panel = labkit.ui.view.section(parentGrid, 'Batch Export', row, [3 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - grid = panel.grid; - uih.btnChooseOutput = uibutton(grid, 'Text', 'Choose folder', ... - 'ButtonPushedFcn', callbacks.chooseOutputFolder); - place(uih.btnChooseOutput, 1, 1); - uih.txtOutputFolder = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', outputFolder)); - place(uih.txtOutputFolder, 1, 2); - [lblFormat, uih.ddFormat] = labkit.ui.view.form(grid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Format:', ... - 'items', {{'PNG', 'TIFF', 'JPEG'}}, ... - 'value', 'PNG')); - place(lblFormat, 2, 1); - place(uih.ddFormat, 2, 2); - uih.btnExport = uibutton(grid, 'Text', 'Export matched images', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', callbacks.exportImages); - place(uih.btnExport, 3, [1 2]); - -end - -function uih = buildExportDetailsSection(uih, parentGrid, row) - panel = labkit.ui.view.section(parentGrid, 'Export Details', row, [1 1], ... - struct('rowHeight', {{105}})); - uih.txtDetails = uitextarea(panel.grid, 'Editable', 'off'); - place(uih.txtDetails, 1, 1); - uih.txtDetails.Value = image_match.view.detailLines( ... - repmat(image_match.state.emptyItem(), 0, 1), 1, ... - repmat(image_match.state.emptyStep(), 0, 1), []); -end - -function place(component, row, column) - component.Layout.Row = row; - component.Layout.Column = column; -end diff --git a/apps/image_measurement/image_match/+image_match/+view/ternary.m b/apps/image_measurement/image_match/+image_match/+view/ternary.m deleted file mode 100644 index 15420b3..0000000 --- a/apps/image_measurement/image_match/+image_match/+view/ternary.m +++ /dev/null @@ -1,10 +0,0 @@ -% Expected caller: labkit_ImageMatch_app UI state updates. Return trueValue -% when condition is true, otherwise falseValue. -function value = ternary(condition, trueValue, falseValue) - - if condition - value = trueValue; - else - value = falseValue; - end -end diff --git a/apps/image_measurement/image_match/+image_match/run.m b/apps/image_measurement/image_match/+image_match/run.m new file mode 100644 index 0000000..f4c330e --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/run.m @@ -0,0 +1,377 @@ +% Expected caller: labkit_ImageMatch_app. Input is the debug context prepared +% by the public launcher. Output is the app figure. Side effects are GUI +% creation, user-driven image loading, matched image export, and debug trace attachment. +function fig = run(debugLog) +%RUN Build and run the Image Match app body. + + S = struct(); + S.items = repmat(image_match.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.outputFolder = string(pwd); + S.lastExport = []; + S.pendingDirty = false; + + methods = {'Balanced', 'White balance', 'Tone only', 'Lab style', 'Histogram'}; + callbacks = struct( ... + 'sourceImagesChosen', @onSourceImagesChosen, ... + 'clearImages', @onClearImages, ... + 'imageSelectionChanged', @onImageSelectionChanged, ... + 'previewModeChanged', @onPreviewModeChanged, ... + 'matchSettingChanged', @onMatchSettingChanged, ... + 'applyMatch', @onApplyMatch, ... + 'undoHistory', @onUndoHistory, ... + 'resetHistory', @onResetHistory, ... + 'chooseOutputFolder', @onChooseOutputFolder, ... + 'exportImages', @onExportImages); + spec = image_match.ui.buildSpec(methods, char(S.outputFolder), callbacks); + ui = labkit.ui.app.create(spec, 'debug', debugLog); + fig = ui.figure; + if debugLog.enabled + debugLog.trace('Image match debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + resetPreviewAxes(); + refreshAll(); + + function onSourceImagesChosen(~, event) + try + S.items = image_match.io.readImages(event.paths); + catch ME + showError('Could not load images', ME.message); + refreshAll(); + return; + end + + S.currentIndex = 1; + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.outputFolder = string(fileparts(event.paths{1})); + S.lastExport = []; + addLog(sprintf('Loaded %d image(s).', numel(S.items))); + refreshAll(); + end + + function onClearImages(~, ~) + S.items = repmat(image_match.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Cleared loaded images and match history.'); + refreshAll(); + end + + function onImageSelectionChanged(~, event) + if isempty(S.items) || isempty(event.value) + return; + end + selectedPath = string(event.value); + idx = find(string({S.items.path}) == selectedPath, 1); + if isempty(idx) + return; + end + S.currentIndex = idx; + refreshSelection(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + end + + function onPreviewModeChanged(~, ~) + refreshPreview(); + end + + function onMatchSettingChanged(~, ~) + S.pendingDirty = true; + S.lastExport = []; + refreshPreview(); + refreshMatchStatus(); + end + + function onApplyMatch(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before applying reference matches.'); + return; + end + step = currentMatchStep(); + S.steps(end + 1, 1) = step; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Applied match: %s', char(step.label))); + refreshAll(); + end + + function onUndoHistory(~, ~) + if isempty(S.steps) + return; + end + removed = S.steps(end); + S.steps(end) = []; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Undid match step: %s', char(removed.label))); + refreshAll(); + end + + function onResetHistory(~, ~) + if isempty(S.steps) + return; + end + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Reset match history.'); + refreshAll(); + end + + function onChooseOutputFolder(~, ~) + folder = uigetdir(char(S.outputFolder), 'Select image match export folder'); + if isequal(folder, 0) + addLog('Export folder selection cancelled.'); + return; + end + S.outputFolder = string(folder); + refreshExportControls(); + refreshDetails(); + end + + function onExportImages(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before exporting matched outputs.'); + return; + end + opts = struct(); + opts.outputFolder = S.outputFolder; + opts.format = labkit.ui.view.getValue(ui, 'exportFormat'); + busyOpts = struct(); + busyOpts.title = 'Export matched images'; + busyOpts.message = 'Writing matched image outputs...'; + busyOpts.controls = exportBusyControls(); + try + S.lastExport = labkit.ui.app.runBusy(fig, ... + @() image_match.export.writeOutputs(S.items, S.steps, opts), busyOpts); + catch ME + showError('Export failed', ME.message); + return; + end + statuses = string({S.lastExport.results.status}); + addLog(sprintf('Exported %d image(s), %d failed. Manifest: %s', ... + sum(statuses == "saved"), sum(statuses == "failed"), ... + char(S.lastExport.manifestPath))); + refreshDetails(); + end + + function controls = exportBusyControls() + controls = { ... + ui.controls.sourceImages.chooseButton ... + ui.controls.sourceImages.clearButton ... + ui.controls.sourceImages.listbox ... + ui.controls.preview.viewModeDropDown ... + ui.controls.referenceImage.handle ... + ui.controls.matchMethod.handle ... + ui.controls.matchStrength.handle ... + ui.controls.toneStrength.handle ... + ui.controls.colorStrength.handle ... + ui.controls.applyMatch.button ... + ui.controls.undoHistory.button ... + ui.controls.resetHistory.button ... + ui.controls.chooseOutputFolder.button ... + ui.controls.exportFormat.handle ... + ui.controls.exportImages.button}; + end + + function refreshAll() + refreshSourceLibrary(); + refreshSelection(); + refreshMatchControls(); + refreshExportControls(); + refreshHistory(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + refreshMatchStatus(); + end + + function refreshSourceLibrary() + if isempty(S.items) + labkit.ui.view.setValue(ui, 'sourceImages', {}); + labkit.ui.view.setValue(ui, 'imageStatus', 'Images: 0'); + referenceHandle = ui.controls.referenceImage.handle; + referenceHandle.Items = {'No reference'}; + referenceHandle.Value = 'No reference'; + return; + end + + paths = cellstr(string({S.items.path})); + labkit.ui.view.setValue(ui, 'sourceImages', paths); + labkit.ui.view.setValue(ui, 'imageStatus', sprintf( ... + 'Images: %d | match steps: %d', numel(S.items), numel(S.steps))); + + names = image_match.view.displayImageNames(S.items); + referenceHandle = ui.controls.referenceImage.handle; + previousReference = referenceHandle.Value; + referenceHandle.Items = names; + if any(strcmp(names, previousReference)) + referenceHandle.Value = previousReference; + else + referenceHandle.Value = names{currentSelectionIndex()}; + end + end + + function refreshSelection() + if isempty(S.items) + return; + end + paths = cellstr(string({S.items.path})); + labkit.ui.view.setListSelection(ui, 'sourceImages', paths, ... + paths{currentSelectionIndex()}, struct()); + end + + function refreshMatchControls() + hasImages = ~isempty(S.items); + hasSteps = ~isempty(S.steps); + ui.controls.sourceImages.clearButton.Enable = onOff(hasImages); + ui.controls.sourceImages.listbox.Enable = onOff(hasImages); + labkit.ui.view.setEnabled(ui, 'referenceImage', hasImages); + labkit.ui.view.setEnabled(ui, 'applyMatch', hasImages); + labkit.ui.view.setEnabled(ui, 'undoHistory', hasSteps); + labkit.ui.view.setEnabled(ui, 'resetHistory', hasSteps); + labkit.ui.view.setEnabled(ui, 'exportImages', hasImages); + end + + function refreshExportControls() + labkit.ui.view.setValue(ui, 'outputFolder', char(S.outputFolder)); + end + + function refreshPreview() + if isempty(S.items) + resetPreviewAxes(); + return; + end + original = S.items(currentSelectionIndex()).image; + processed = currentProcessedImages(S.pendingDirty); + matched = processed{currentSelectionIndex()}; + switch currentPreviewMode() + case 'Original' + labkit.ui.view.drawImage(ui, 'preview', original, ... + 'title', 'Original Preview'); + case 'Before | After' + labkit.ui.view.drawImage(ui, 'preview', ... + image_match.view.beforeAfterImage(original, matched), ... + 'title', 'Before | After'); + otherwise + labkit.ui.view.drawImage(ui, 'preview', matched, ... + 'title', 'Matched Preview'); + end + end + + function refreshMetrics() + if isempty(S.items) + ui.controls.metricsTable.table.Data = ... + image_match.view.resultTableData([], [], 0); + return; + end + processed = currentProcessedImages(false); + ui.controls.metricsTable.table.Data = image_match.view.resultTableData( ... + S.items(currentSelectionIndex()), ... + processed{currentSelectionIndex()}, numel(S.steps)); + end + + function refreshHistory() + ui.controls.historyTable.table.Data = image_match.view.historyTableData(S.steps); + labkit.ui.view.setValue(ui, 'historyStatus', ... + sprintf('History steps: %d', numel(S.steps))); + end + + function refreshDetails() + labkit.ui.view.setValue(ui, 'exportDetails', image_match.view.detailLines( ... + S.items, max(currentSelectionIndex(), 1), S.steps, S.lastExport)); + end + + function refreshMatchStatus() + labkit.ui.view.setValue(ui, 'matchFlow', ... + image_match.view.matchFlowLines(labkit.ui.view.getValue(ui, 'matchMethod'))); + end + + function processed = currentProcessedImages(includePending) + images = cell(numel(S.items), 1); + for k = 1:numel(S.items) + images{k} = S.items(k).image; + end + steps = S.steps; + if includePending + steps(end + 1, 1) = currentMatchStep(); + end + processed = image_match.ops.applyPipeline(images, steps); + end + + function step = currentMatchStep() + step = image_match.ops.makeStep(currentReferenceIndex(), ... + labkit.ui.view.getValue(ui, 'matchMethod'), ... + labkit.ui.view.getValue(ui, 'matchStrength'), ... + labkit.ui.view.getValue(ui, 'toneStrength'), ... + labkit.ui.view.getValue(ui, 'colorStrength')); + end + + function index = currentReferenceIndex() + index = 0; + if isempty(S.items) + return; + end + names = image_match.view.displayImageNames(S.items); + selectedReference = labkit.ui.view.getValue(ui, 'referenceImage'); + idx = find(strcmp(names, selectedReference), 1); + if ~isempty(idx) + index = idx; + else + index = currentSelectionIndex(); + end + end + + function index = currentSelectionIndex() + if isempty(S.items) + index = 0; + return; + end + S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); + index = S.currentIndex; + end + + function mode = currentPreviewMode() + mode = string(labkit.ui.view.getValue(ui, 'preview')); + if strlength(mode) == 0 + mode = "Matched"; + end + mode = char(mode); + end + + function resetPreviewAxes() + labkit.ui.view.resetAxes(ui, 'preview', 'Matched Preview', true); + end + + function addLog(message) + labkit.ui.view.appendLog(ui, 'logPanel', message); + if debugLog.enabled + debugLog.append(message); + end + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end +end + +function text = onOff(value) + if islogical(value) && isscalar(value) + if value + text = 'on'; + else + text = 'off'; + end + else + text = char(string(value)); + end +end diff --git a/apps/image_measurement/image_match/labkit_ImageMatch_app.m b/apps/image_measurement/image_match/labkit_ImageMatch_app.m index b5de2f8..e2e1987 100644 --- a/apps/image_measurement/image_match/labkit_ImageMatch_app.m +++ b/apps/image_measurement/image_match/labkit_ImageMatch_app.m @@ -17,339 +17,11 @@ 'labkit_ImageMatch_app returns at most the app figure handle.'); end - S = struct(); - S.items = repmat(image_match.state.emptyItem(), 0, 1); - S.currentIndex = 0; - S.steps = repmat(image_match.state.emptyStep(), 0, 1); - S.outputFolder = string(pwd); - S.lastExport = []; - S.pendingDirty = false; - - methods = {'Balanced', 'White balance', 'Tone only', 'Lab style', 'Histogram'}; - callbacks = struct( ... - 'openFiles', @onOpenFiles, ... - 'clearImages', @onClearImages, ... - 'imageSelectionChanged', @onImageSelectionChanged, ... - 'previewModeChanged', @onPreviewModeChanged, ... - 'matchSettingChanged', @onMatchSettingChanged, ... - 'applyMatch', @onApplyMatch, ... - 'undoHistory', @onUndoHistory, ... - 'resetHistory', @onResetHistory, ... - 'chooseOutputFolder', @onChooseOutputFolder, ... - 'exportImages', @onExportImages); - uih = image_match.ui.createEditorUi(methods, char(S.outputFolder), callbacks); - fig = uih.fig; previewAxes = uih.previewAxes; txtLog = uih.txtLog; - btnOpenFiles = uih.btnOpenFiles; btnClearImages = uih.btnClearImages; - lbImages = uih.lbImages; txtImageSource = uih.txtImageSource; - txtImageStatus = uih.txtImageStatus; ddPreviewMode = uih.ddPreviewMode; - ddReference = uih.ddReference; ddMethod = uih.ddMethod; - edtStrength = uih.edtStrength; edtTone = uih.edtTone; - edtColor = uih.edtColor; txtMatchFlow = uih.txtMatchFlow; - btnApplyMatch = uih.btnApplyMatch; btnUndoHistory = uih.btnUndoHistory; - btnResetHistory = uih.btnResetHistory; historyTable = uih.historyTable; - txtHistoryStatus = uih.txtHistoryStatus; resultTable = uih.resultTable; - btnChooseOutput = uih.btnChooseOutput; txtOutputFolder = uih.txtOutputFolder; - ddFormat = uih.ddFormat; btnExport = uih.btnExport; txtDetails = uih.txtDetails; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('Image match debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - resetPreviewAxes(); - refreshAll(); - + fig = image_match.run(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenFiles(~, ~) - [files, folder] = uigetfile(image_match.io.imageDialogFilter(), ... - 'Select images to match', pwd, 'MultiSelect', 'on'); - if isequal(files, 0) - addLog('Image file selection cancelled.'); - return; - end - try - paths = image_match.io.selectedImagePaths(files, folder); - S.items = image_match.io.readImages(paths); - catch ME - showError('Could not load images', ME.message); - return; - end - - S.currentIndex = 1; - S.steps = repmat(image_match.state.emptyStep(), 0, 1); - S.pendingDirty = false; - S.outputFolder = string(folder); - S.lastExport = []; - txtOutputFolder.Value = char(S.outputFolder); - addLog(sprintf('Loaded %d image(s).', numel(S.items))); - refreshAll(); - end - - function onClearImages(~, ~) - S.items = repmat(image_match.state.emptyItem(), 0, 1); - S.currentIndex = 0; - S.steps = repmat(image_match.state.emptyStep(), 0, 1); - S.pendingDirty = false; - S.lastExport = []; - addLog('Cleared loaded images and match history.'); - refreshAll(); - end - - function onImageSelectionChanged(~, ~) - if isempty(S.items) - return; - end - names = image_match.view.displayImageNames(S.items); - idx = find(strcmp(names, lbImages.Value), 1); - if isempty(idx) - return; - end - S.currentIndex = idx; - refreshSelection(); - refreshPreview(); - refreshMetrics(); - refreshDetails(); - end - - function onPreviewModeChanged(~, ~) - refreshPreview(); - end - - function onMatchSettingChanged(~, ~) - S.pendingDirty = true; - S.lastExport = []; - refreshPreview(); - refreshMatchStatus(); - end - - function onApplyMatch(~, ~) - if isempty(S.items) - showError('No images loaded', 'Load images before applying reference matches.'); - return; - end - step = currentMatchStep(); - S.steps(end + 1, 1) = step; - S.pendingDirty = false; - S.lastExport = []; - addLog(sprintf('Applied match: %s', char(step.label))); - refreshAll(); - end - - function onUndoHistory(~, ~) - if isempty(S.steps) - return; - end - removed = S.steps(end); - S.steps(end) = []; - S.pendingDirty = false; - S.lastExport = []; - addLog(sprintf('Undid match step: %s', char(removed.label))); - refreshAll(); - end - - function onResetHistory(~, ~) - if isempty(S.steps) - return; - end - S.steps = repmat(image_match.state.emptyStep(), 0, 1); - S.pendingDirty = false; - S.lastExport = []; - addLog('Reset match history.'); - refreshAll(); - end - - function onChooseOutputFolder(~, ~) - folder = uigetdir(char(S.outputFolder), 'Select image match export folder'); - if isequal(folder, 0) - addLog('Export folder selection cancelled.'); - return; - end - S.outputFolder = string(folder); - txtOutputFolder.Value = char(S.outputFolder); - refreshDetails(); - end - - function onExportImages(~, ~) - if isempty(S.items) - showError('No images loaded', 'Load images before exporting matched outputs.'); - return; - end - opts = struct(); - opts.outputFolder = S.outputFolder; - opts.format = ddFormat.Value; - busyOpts = struct(); - busyOpts.title = 'Export matched images'; - busyOpts.message = 'Writing matched image outputs...'; - busyOpts.controls = exportBusyControls(); - try - S.lastExport = labkit.ui.app.runBusy(fig, ... - @() image_match.export.writeOutputs(S.items, S.steps, opts), busyOpts); - catch ME - showError('Export failed', ME.message); - return; - end - statuses = string({S.lastExport.results.status}); - addLog(sprintf('Exported %d image(s), %d failed. Manifest: %s', ... - sum(statuses == "saved"), sum(statuses == "failed"), ... - char(S.lastExport.manifestPath))); - refreshDetails(); - end - - function controls = exportBusyControls() - controls = {btnOpenFiles, btnClearImages, lbImages, ddPreviewMode, ... - ddReference, ddMethod, edtStrength, edtTone, edtColor, ... - btnApplyMatch, btnUndoHistory, btnResetHistory, btnChooseOutput, ... - ddFormat, btnExport}; - end - - function refreshAll() - refreshList(); - refreshControls(); - refreshSelection(); - refreshHistory(); - refreshPreview(); - refreshMetrics(); - refreshDetails(); - refreshMatchStatus(); - end - - function refreshList() - if isempty(S.items) - lbImages.Items = {'No images loaded'}; - lbImages.Value = 'No images loaded'; - txtImageSource.Value = 'No images loaded'; - txtImageStatus.Value = 'Images: 0'; - ddReference.Items = {'No reference'}; - ddReference.Value = 'No reference'; - return; - end - names = image_match.view.displayImageNames(S.items); - S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); - lbImages.Items = names; - lbImages.Value = names{S.currentIndex}; - txtImageStatus.Value = sprintf('Images: %d | match steps: %d', ... - numel(S.items), numel(S.steps)); - previousReference = ddReference.Value; - ddReference.Items = names; - if any(strcmp(names, previousReference)) - ddReference.Value = previousReference; - else - ddReference.Value = names{S.currentIndex}; - end - end - - function refreshSelection() - if isempty(S.items) - txtImageSource.Value = 'No images loaded'; - return; - end - txtImageSource.Value = char(S.items(S.currentIndex).path); - end - - function refreshControls() - hasImages = ~isempty(S.items); - hasSteps = ~isempty(S.steps); - btnClearImages.Enable = image_match.view.ternary(hasImages, 'on', 'off'); - ddReference.Enable = image_match.view.ternary(hasImages, 'on', 'off'); - btnApplyMatch.Enable = image_match.view.ternary(hasImages, 'on', 'off'); - btnUndoHistory.Enable = image_match.view.ternary(hasSteps, 'on', 'off'); - btnResetHistory.Enable = image_match.view.ternary(hasSteps, 'on', 'off'); - btnExport.Enable = image_match.view.ternary(hasImages, 'on', 'off'); - end - - function refreshPreview() - if isempty(S.items) - resetPreviewAxes(); - return; - end - original = S.items(S.currentIndex).image; - processed = currentProcessedImages(S.pendingDirty); - matched = processed{S.currentIndex}; - switch ddPreviewMode.Value - case 'Original' - labkit.ui.view.draw(previewAxes, 'image', original, 'Original Preview'); - case 'Before | After' - labkit.ui.view.draw(previewAxes, 'image', ... - image_match.view.beforeAfterImage(original, matched), 'Before | After'); - otherwise - labkit.ui.view.draw(previewAxes, 'image', matched, 'Matched Preview'); - end - end - - function refreshMetrics() - if isempty(S.items) - resultTable.Data = image_match.view.resultTableData([], [], 0); - return; - end - processed = currentProcessedImages(false); - resultTable.Data = image_match.view.resultTableData( ... - S.items(S.currentIndex), processed{S.currentIndex}, numel(S.steps)); - end - - function refreshHistory() - historyTable.Data = image_match.view.historyTableData(S.steps); - txtHistoryStatus.Value = sprintf('History steps: %d', numel(S.steps)); - end - - function refreshDetails() - txtDetails.Value = image_match.view.detailLines( ... - S.items, max(S.currentIndex, 1), S.steps, S.lastExport); - end - - function refreshMatchStatus() - txtMatchFlow.Value = image_match.view.matchFlowLines(ddMethod.Value); - end - - function processed = currentProcessedImages(includePending) - images = cell(numel(S.items), 1); - for k = 1:numel(S.items) - images{k} = S.items(k).image; - end - steps = S.steps; - if includePending - steps(end + 1, 1) = currentMatchStep(); - end - processed = image_match.ops.applyPipeline(images, steps); - end - - function step = currentMatchStep() - step = image_match.ops.makeStep(currentReferenceIndex(), ddMethod.Value, ... - edtStrength.Value, edtTone.Value, edtColor.Value); - end - - function index = currentReferenceIndex() - index = 0; - if isempty(S.items) - return; - end - names = image_match.view.displayImageNames(S.items); - idx = find(strcmp(names, ddReference.Value), 1); - if ~isempty(idx) - index = idx; - elseif S.currentIndex > 0 - index = S.currentIndex; - end - end - - function resetPreviewAxes() - labkit.ui.view.draw(previewAxes, 'reset', 'Matched Preview', true); - end - - function addLog(message) - labkit.ui.view.update(txtLog, 'appendLog', message); - if debugLog.enabled - debugLog.append(message); - end - end - - function showError(titleText, message) - addLog(sprintf('%s: %s', titleText, message)); - uialert(fig, message, titleText); - end end diff --git a/apps/wearable/ecg_print/+ecg_print/+ui/buildSpec.m b/apps/wearable/ecg_print/+ecg_print/+ui/buildSpec.m new file mode 100644 index 0000000..fc15bef --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+ui/buildSpec.m @@ -0,0 +1,191 @@ +% Expected caller: ecg_print.ui.runApp. Input is a callback struct whose fields +% are app-owned callback handles. Output is a data-only UI 2.0 workbench spec +% for the ECG Print app. +function spec = buildSpec(callbacks) + + spec = labkit.ui.spec.app("ecgPrintApp", ... + "ECG Signal Print + SNR Explorer", ... + "position", [80 70 1480 880], ... + "leftWidth", 410, ... + "controlTabs", { ... + labkit.ui.spec.tab("filesAnalysis", "Files + Analysis", { ... + labkit.ui.spec.section("recordingSection", "Recording", { ... + labkit.ui.spec.pathPanel("recording", "Recording", ... + "mode", "singleFile", ... + "chooseLabel", "Open recording", ... + "clearLabel", "Clear recording", ... + "filters", { ... + '*.mat;*.csv;*.txt;*.tsv', ... + 'Biosignal files (*.mat, *.csv, *.txt, *.tsv)'; ... + '*.*', 'All files'}, ... + "dialogTitle", "Select biosignal recording", ... + "status", "No file loaded", ... + "emptyText", "No file loaded", ... + "onChoose", callbackValue(callbacks, ... + "recordingChosen"), ... + "onClear", callbackValue(callbacks, ... + "clearRecording")), ... + labkit.ui.spec.action("previewHeader", ... + "Preview file header", callbackValue(callbacks, ... + "previewHeader"))}, ... + "height", 140), ... + labkit.ui.spec.section("importSection", "Import Parsing", { ... + labkit.ui.spec.field("importStatus", "Status", ... + "kind", "readonly", ... + "value", "Open a recording to inspect import settings."), ... + labkit.ui.spec.field("headerLine", "CSV header line:", ... + "kind", "spinner", ... + "value", 0, ... + "limits", [0 Inf], ... + "step", 1, ... + "onChange", callbackValue(callbacks, ... + "importOptionChanged")), ... + labkit.ui.spec.field("hasHeader", "CSV header:", ... + "kind", "dropdown", ... + "items", {'Auto', 'Yes', 'No'}, ... + "value", "Auto", ... + "onChange", callbackValue(callbacks, ... + "importOptionChanged")), ... + labkit.ui.spec.field("timeColumn", "Time column:", ... + "kind", "text", ... + "value", "", ... + "onChange", callbackValue(callbacks, ... + "importOptionChanged")), ... + labkit.ui.spec.field("timeUnit", "Time unit:", ... + "kind", "dropdown", ... + "items", {'Auto', 'seconds', 'milliseconds', ... + 'microseconds', 'nanoseconds'}, ... + "value", "Auto", ... + "onChange", callbackValue(callbacks, ... + "importOptionChanged")), ... + labkit.ui.spec.field("signalColumns", "Signal columns:", ... + "kind", "text", ... + "value", "", ... + "onChange", callbackValue(callbacks, ... + "importOptionChanged")), ... + labkit.ui.spec.field("fallbackFs", "Fallback Fs:", ... + "kind", "spinner", ... + "value", 2000, ... + "limits", [0 Inf], ... + "step", 100, ... + "onChange", callbackValue(callbacks, ... + "importOptionChanged")), ... + labkit.ui.spec.action("refreshImport", ... + "Parse / refresh file", callbackValue(callbacks, ... + "refreshImport"))}, ... + "height", 255), ... + labkit.ui.spec.section("channelSection", "Channel + ROI", { ... + labkit.ui.spec.field("channel", "Channel:", ... + "kind", "dropdown", ... + "items", {'(none)'}, ... + "value", "(none)", ... + "onChange", callbackValue(callbacks, "channelChanged")), ... + labkit.ui.spec.field("roiStart", "ROI start (s):", ... + "kind", "spinner", ... + "value", 0, ... + "limits", [0 Inf], ... + "step", 1), ... + labkit.ui.spec.field("roiEnd", "ROI end (s):", ... + "kind", "spinner", ... + "value", 0, ... + "limits", [0 Inf], ... + "step", 1)}, ... + "height", 120), ... + labkit.ui.spec.section("processingSection", ... + "Signal Processing + SNR", { ... + labkit.ui.spec.field("lowCut", "Bandpass low Hz:", ... + "kind", "spinner", ... + "value", 0.5, ... + "limits", [0 Inf], ... + "step", 0.1), ... + labkit.ui.spec.field("highCut", "Bandpass high Hz:", ... + "kind", "spinner", ... + "value", 40, ... + "limits", [0 Inf], ... + "step", 1), ... + labkit.ui.spec.field("peakMethod", "Peak method:", ... + "kind", "dropdown", ... + "items", {'QRS streaming', 'Pan-Tompkins', ... + 'Local peaks'}, ... + "value", "QRS streaming"), ... + labkit.ui.spec.field("peakDistance", ... + "Peak distance (s):", ... + "kind", "spinner", ... + "value", 0.28, ... + "limits", [0.01 Inf], ... + "step", 0.01), ... + labkit.ui.spec.field("segmentWindow", ... + "Segment half win (s):", ... + "kind", "spinner", ... + "value", 0.7, ... + "limits", [0.01 Inf], ... + "step", 0.05), ... + labkit.ui.spec.field("templateTopN", "Template top N:", ... + "kind", "spinner", ... + "value", 30, ... + "limits", [1 Inf], ... + "step", 1), ... + labkit.ui.spec.field("smoothBeats", "Smooth beats:", ... + "kind", "spinner", ... + "value", 15, ... + "limits", [1 Inf], ... + "step", 1, ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.field("templateView", "Template plot:", ... + "kind", "dropdown", ... + "items", {'Template + residual band', ... + 'Template + segments'}, ... + "value", "Template + residual band", ... + "onChange", callbackValue(callbacks, "refreshPlots")), ... + labkit.ui.spec.action("analyze", ... + "Analyze current ROI", callbackValue(callbacks, ... + "analyze"))}, ... + "height", 235), ... + labkit.ui.spec.section("exportSection", "Exports", { ... + labkit.ui.spec.action("exportSegments", ... + "Export segment SNR CSV", callbackValue(callbacks, ... + "exportSegments")), ... + labkit.ui.spec.action("exportWaveform", ... + "Export waveform PNG", callbackValue(callbacks, ... + "exportWaveform"))}, ... + "height", 100), ... + labkit.ui.spec.section("workflowNotes", "Workflow Notes", { ... + labkit.ui.spec.statusPanel("workflowNotesText", ... + "Workflow Notes", ... + "value", { ... + '1. Open MAT/CSV data, select a numeric channel, and optionally set a time ROI.', ... + '2. Use File Header Preview and Import Parsing only when CSV/text auto-detection needs correction.', ... + '3. Analysis filters the selected channel with edge padding, then crops the filtered signal to the ROI for peak/SNR measurement.'})}, ... + "height", 125)}), ... + labkit.ui.spec.tab("summaryResults", "Summary + Results", { ... + labkit.ui.spec.section("summarySection", "Summary", { ... + labkit.ui.spec.resultTable("summaryTable", "Summary", ... + "columns", {'Metric', 'Value'}, ... + "data", ecg_print.view.initialSummaryRows()), ... + labkit.ui.spec.statusPanel("filePreview", ... + "File Header Preview", ... + "value", { ... + 'Open a CSV/text file, then use Preview file header.'})})}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.section("logSection", "Log", { ... + labkit.ui.spec.logPanel("appLog", "Log", ... + "value", {'Ready.'})})})}, ... + "workspace", labkit.ui.spec.workspace("ecgPreview", "ECG Preview", { ... + labkit.ui.spec.previewArea("previewAxes", "ECG Preview", ... + "layout", "stack", ... + "count", 4, ... + "axisIds", {'wave', 'noise', 'snr', 'template'}, ... + "axisTitles", {'Waveform + Peaks', ... + 'Template Noise RMS Over Time', ... + 'Template SNR Over Time', ... + 'Template + Residual Band'})}, ... + "rowSpacing", 8)); +end + +function value = callbackValue(callbacks, fieldName) + value = []; + fieldName = char(fieldName); + if isstruct(callbacks) && isfield(callbacks, fieldName) + value = callbacks.(fieldName); + end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+ui/createControls.m b/apps/wearable/ecg_print/+ecg_print/+ui/createControls.m deleted file mode 100644 index d851e00..0000000 --- a/apps/wearable/ecg_print/+ecg_print/+ui/createControls.m +++ /dev/null @@ -1,316 +0,0 @@ -% Expected caller: ecg_print.ui.runApp. Input is the LabKit shell struct plus -% app callback handles. Output is a struct of app-specific UI handles used by -% the runner. Side effects are limited to constructing ECG Print controls, -% panels, tables, text areas, and axes under the supplied shell. - -function controls = createControls(ui, callbacks) -%CREATECONTROLS Build ECG Print app-specific controls and axes. - - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - recordingPanel = labkit.ui.view.section(layFA, 'Recording', 1, [3 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 3)}, ... - 'columnWidth', {{135, '1x'}})); - recordingGrid = recordingPanel.grid; - - btnOpen = uibutton(recordingGrid, 'Text', 'Open recording', ... - 'ButtonPushedFcn', callbacks.onOpenRecording); - btnOpen.Layout.Row = 1; - btnOpen.Layout.Column = [1 2]; - - txtFile = labkit.ui.view.form(recordingGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'No file loaded')); - txtFile.Layout.Row = 2; - txtFile.Layout.Column = [1 2]; - - btnPreviewHeader = uibutton(recordingGrid, 'Text', 'Preview file header', ... - 'ButtonPushedFcn', callbacks.onPreviewHeader); - btnPreviewHeader.Layout.Row = 3; - btnPreviewHeader.Layout.Column = [1 2]; - - importPanel = labkit.ui.view.section(layFA, 'Import Parsing', 2, [8 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 8)}, ... - 'columnWidth', {{135, '1x'}})); - importGrid = importPanel.grid; - - txtImportStatus = labkit.ui.view.form(importGrid, struct( ... - 'kind', 'readonly', ... - 'value', 'Open a recording to inspect import settings.')); - txtImportStatus.Layout.Row = 1; - txtImportStatus.Layout.Column = [1 2]; - - [lblHeaderLine, edtHeaderLine] = labkit.ui.view.form(importGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'CSV header line:', ... - 'value', 0, ... - 'limits', [0 Inf], ... - 'step', 1, ... - 'callback', callbacks.onImportOptionChanged)); - lblHeaderLine.Layout.Row = 2; - lblHeaderLine.Layout.Column = 1; - edtHeaderLine.Layout.Row = 2; - edtHeaderLine.Layout.Column = 2; - - [lblHasHeader, ddHasHeader] = labkit.ui.view.form(importGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'CSV header:', ... - 'items', {{'Auto', 'Yes', 'No'}}, ... - 'value', 'Auto', ... - 'callback', callbacks.onImportOptionChanged)); - lblHasHeader.Layout.Row = 3; - lblHasHeader.Layout.Column = 1; - ddHasHeader.Layout.Row = 3; - ddHasHeader.Layout.Column = 2; - - [lblTimeColumn, edtTimeColumn] = labkit.ui.view.form(importGrid, struct( ... - 'kind', 'edit', ... - 'label', 'Time column:', ... - 'style', 'text', ... - 'value', '', ... - 'callback', callbacks.onImportOptionChanged)); - lblTimeColumn.Layout.Row = 4; - lblTimeColumn.Layout.Column = 1; - edtTimeColumn.Layout.Row = 4; - edtTimeColumn.Layout.Column = 2; - - [lblTimeUnit, ddTimeUnit] = labkit.ui.view.form(importGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Time unit:', ... - 'items', {{'Auto', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds'}}, ... - 'value', 'Auto', ... - 'callback', callbacks.onImportOptionChanged)); - lblTimeUnit.Layout.Row = 5; - lblTimeUnit.Layout.Column = 1; - ddTimeUnit.Layout.Row = 5; - ddTimeUnit.Layout.Column = 2; - - [lblSignalColumns, edtSignalColumns] = labkit.ui.view.form(importGrid, struct( ... - 'kind', 'edit', ... - 'label', 'Signal columns:', ... - 'style', 'text', ... - 'value', '', ... - 'callback', callbacks.onImportOptionChanged)); - lblSignalColumns.Layout.Row = 6; - lblSignalColumns.Layout.Column = 1; - edtSignalColumns.Layout.Row = 6; - edtSignalColumns.Layout.Column = 2; - - [lblFallbackFs, edtFallbackFs] = labkit.ui.view.form(importGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Fallback Fs:', ... - 'value', 2000, ... - 'limits', [0 Inf], ... - 'step', 100, ... - 'callback', callbacks.onImportOptionChanged)); - lblFallbackFs.Layout.Row = 7; - lblFallbackFs.Layout.Column = 1; - edtFallbackFs.Layout.Row = 7; - edtFallbackFs.Layout.Column = 2; - - btnRefreshImport = uibutton(importGrid, 'Text', 'Parse / refresh file', ... - 'ButtonPushedFcn', callbacks.onRefreshImport); - btnRefreshImport.Layout.Row = 8; - btnRefreshImport.Layout.Column = [1 2]; - - channelPanel = labkit.ui.view.section(layFA, 'Channel + ROI', 3, [3 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 3)}, ... - 'columnWidth', {{135, '1x'}})); - channelGrid = channelPanel.grid; - - [lblChannel, ddChannel] = labkit.ui.view.form(channelGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Channel:', ... - 'items', {{'(none)'}}, ... - 'value', '(none)', ... - 'callback', callbacks.onChannelChanged)); - lblChannel.Layout.Row = 1; - lblChannel.Layout.Column = 1; - ddChannel.Layout.Row = 1; - ddChannel.Layout.Column = 2; - - [lblStart, edtStart] = labkit.ui.view.form(channelGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'ROI start (s):', ... - 'value', 0, ... - 'limits', [0 Inf], ... - 'step', 1)); - lblStart.Layout.Row = 2; - lblStart.Layout.Column = 1; - edtStart.Layout.Row = 2; - edtStart.Layout.Column = 2; - - [lblEnd, edtEnd] = labkit.ui.view.form(channelGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'ROI end (s):', ... - 'value', 0, ... - 'limits', [0 Inf], ... - 'step', 1)); - lblEnd.Layout.Row = 3; - lblEnd.Layout.Column = 1; - edtEnd.Layout.Row = 3; - edtEnd.Layout.Column = 2; - - procPanel = labkit.ui.view.section(layFA, 'Signal Processing + SNR', 4, [9 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 9)}, ... - 'columnWidth', {{135, '1x'}})); - procGrid = procPanel.grid; - - [lblLow, edtLow] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Bandpass low Hz:', ... - 'value', 0.5, ... - 'limits', [0 Inf], ... - 'step', 0.1)); - lblLow.Layout.Row = 1; - lblLow.Layout.Column = 1; - edtLow.Layout.Row = 1; - edtLow.Layout.Column = 2; - - [lblHigh, edtHigh] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Bandpass high Hz:', ... - 'value', 40, ... - 'limits', [0 Inf], ... - 'step', 1)); - lblHigh.Layout.Row = 2; - lblHigh.Layout.Column = 1; - edtHigh.Layout.Row = 2; - edtHigh.Layout.Column = 2; - - [lblPeakMethod, ddPeakMethod] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Peak method:', ... - 'items', {{'QRS streaming', 'Pan-Tompkins', 'Local peaks'}}, ... - 'value', 'QRS streaming')); - lblPeakMethod.Layout.Row = 3; - lblPeakMethod.Layout.Column = 1; - ddPeakMethod.Layout.Row = 3; - ddPeakMethod.Layout.Column = 2; - - [lblPeakDist, edtPeakDist] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Peak distance (s):', ... - 'value', 0.28, ... - 'limits', [0.01 Inf], ... - 'step', 0.01)); - lblPeakDist.Layout.Row = 4; - lblPeakDist.Layout.Column = 1; - edtPeakDist.Layout.Row = 4; - edtPeakDist.Layout.Column = 2; - - [lblWin, edtWin] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Segment half win (s):', ... - 'value', 0.7, ... - 'limits', [0.01 Inf], ... - 'step', 0.05)); - lblWin.Layout.Row = 5; - lblWin.Layout.Column = 1; - edtWin.Layout.Row = 5; - edtWin.Layout.Column = 2; - - [lblTopN, edtTopN] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Template top N:', ... - 'value', 30, ... - 'limits', [1 Inf], ... - 'step', 1)); - lblTopN.Layout.Row = 6; - lblTopN.Layout.Column = 1; - edtTopN.Layout.Row = 6; - edtTopN.Layout.Column = 2; - - [lblSmooth, edtSmooth] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'spinner', ... - 'label', 'Smooth beats:', ... - 'value', 15, ... - 'limits', [1 Inf], ... - 'step', 1, ... - 'callback', callbacks.onRefreshPlots)); - lblSmooth.Layout.Row = 7; - lblSmooth.Layout.Column = 1; - edtSmooth.Layout.Row = 7; - edtSmooth.Layout.Column = 2; - - [lblView, ddTemplateView] = labkit.ui.view.form(procGrid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Template plot:', ... - 'items', {{'Template + residual band', 'Template + segments'}}, ... - 'value', 'Template + residual band', ... - 'callback', callbacks.onRefreshPlots)); - lblView.Layout.Row = 8; - lblView.Layout.Column = 1; - ddTemplateView.Layout.Row = 8; - ddTemplateView.Layout.Column = 2; - - btnAnalyze = uibutton(procGrid, 'Text', 'Analyze current ROI', ... - 'ButtonPushedFcn', callbacks.onAnalyze); - btnAnalyze.Layout.Row = 9; - btnAnalyze.Layout.Column = [1 2]; - - exportPanel = labkit.ui.view.section(layFA, 'Exports', 5, [2 1], ... - struct('rowHeight', {{'fit','fit'}})); - exportGrid = exportPanel.grid; - btnExportSegments = uibutton(exportGrid, 'Text', 'Export segment SNR CSV', ... - 'ButtonPushedFcn', callbacks.onExportSegments); - btnExportSegments.Layout.Row = 1; - btnExportOverlay = uibutton(exportGrid, 'Text', 'Export waveform PNG', ... - 'ButtonPushedFcn', callbacks.onExportWaveform); - btnExportOverlay.Layout.Row = 2; - - labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 6, { ... - '1. Open MAT/CSV data, select a numeric channel, and optionally set a time ROI.', ... - '2. Use File Header Preview and Import Parsing only when CSV/text auto-detection needs correction.', ... - '3. Analysis filters the selected channel with edge padding, then crops the filtered signal to the ROI for peak/SNR measurement.'}); - - summaryTable = uitable(laySR, 'ColumnName', {'Metric','Value'}, ... - 'Data', ecg_print.view.initialSummaryRows()); - labkit.ui.view.place(summaryTable, laySR, 1); - - previewUi = labkit.ui.view.panel(laySR, 'text', 'File Header Preview', 2, ... - {'Open a CSV/text file, then use Preview file header.'}); - txtFilePreview = previewUi.textArea; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - txtLog = logUi.textArea; - - waveAxes = uiaxes(ui.rightGrid); - waveAxes.Layout.Row = 1; - noiseAxes = uiaxes(ui.rightGrid); - noiseAxes.Layout.Row = 2; - snrAxes = uiaxes(ui.rightGrid); - snrAxes.Layout.Row = 3; - templateAxes = uiaxes(ui.rightGrid); - templateAxes.Layout.Row = 4; - - controls = struct( ... - 'txtFile', txtFile, ... - 'txtImportStatus', txtImportStatus, ... - 'edtHeaderLine', edtHeaderLine, ... - 'ddHasHeader', ddHasHeader, ... - 'edtTimeColumn', edtTimeColumn, ... - 'ddTimeUnit', ddTimeUnit, ... - 'edtSignalColumns', edtSignalColumns, ... - 'edtFallbackFs', edtFallbackFs, ... - 'ddChannel', ddChannel, ... - 'edtStart', edtStart, ... - 'edtEnd', edtEnd, ... - 'edtLow', edtLow, ... - 'edtHigh', edtHigh, ... - 'ddPeakMethod', ddPeakMethod, ... - 'edtPeakDist', edtPeakDist, ... - 'edtWin', edtWin, ... - 'edtTopN', edtTopN, ... - 'edtSmooth', edtSmooth, ... - 'ddTemplateView', ddTemplateView, ... - 'summaryTable', summaryTable, ... - 'txtFilePreview', txtFilePreview, ... - 'txtLog', txtLog, ... - 'waveAxes', waveAxes, ... - 'noiseAxes', noiseAxes, ... - 'snrAxes', snrAxes, ... - 'templateAxes', templateAxes); -end diff --git a/apps/wearable/ecg_print/+ecg_print/+ui/runApp.m b/apps/wearable/ecg_print/+ecg_print/run.m similarity index 78% rename from apps/wearable/ecg_print/+ecg_print/+ui/runApp.m rename to apps/wearable/ecg_print/+ecg_print/run.m index 4fbcb3e..3b5fa5d 100644 --- a/apps/wearable/ecg_print/+ecg_print/+ui/runApp.m +++ b/apps/wearable/ecg_print/+ecg_print/run.m @@ -2,8 +2,8 @@ % Input is the debug context prepared by the public launcher. Output is the app % figure. Side effects are GUI creation, user-driven file I/O, exports, % plotting, and debug trace attachment exactly as in the original entrypoint body. -function fig = runApp(debugLog) -%RUNAPP Build and run the ECG Print app body. +function fig = run(debugLog) +%RUN Build and run the ECG Print app body. S = struct(); S.recording = []; @@ -16,90 +16,76 @@ S.measurements = []; S.filepath = ""; - opts = struct( ... - 'rightTitle', 'ECG Preview', ... - 'rightGridSize', [4 1], ... - 'rightRowHeight', {{'1.2x', '1x', '1x', '1x'}}, ... - 'rightRowSpacing', 8); - opts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [6 1], ... - {140, 255, 120, 235, 100, 125}), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {210, '1x'}), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'ECG Signal Print + SNR Explorer', ... - 'position', [80 70 1480 880], ... - 'leftWidth', 410, ... - 'options', opts)); - fig = ui.fig; - callbacks = struct( ... - 'onOpenRecording', @onOpenRecording, ... - 'onPreviewHeader', @onPreviewHeader, ... - 'onImportOptionChanged', @onImportOptionChanged, ... - 'onRefreshImport', @onRefreshImport, ... - 'onChannelChanged', @onChannelChanged, ... - 'onAnalyze', @onAnalyze, ... - 'onExportSegments', @onExportSegments, ... - 'onExportWaveform', @onExportWaveform, ... - 'onRefreshPlots', @(~,~) refreshPlots()); - controls = ecg_print.ui.createControls(ui, callbacks); - - txtFile = controls.txtFile; - txtImportStatus = controls.txtImportStatus; - edtHeaderLine = controls.edtHeaderLine; - ddHasHeader = controls.ddHasHeader; - edtTimeColumn = controls.edtTimeColumn; - ddTimeUnit = controls.ddTimeUnit; - edtSignalColumns = controls.edtSignalColumns; - edtFallbackFs = controls.edtFallbackFs; - ddChannel = controls.ddChannel; - edtStart = controls.edtStart; - edtEnd = controls.edtEnd; - edtLow = controls.edtLow; - edtHigh = controls.edtHigh; - ddPeakMethod = controls.ddPeakMethod; - edtPeakDist = controls.edtPeakDist; - edtWin = controls.edtWin; - edtTopN = controls.edtTopN; - edtSmooth = controls.edtSmooth; - ddTemplateView = controls.ddTemplateView; - summaryTable = controls.summaryTable; - txtFilePreview = controls.txtFilePreview; - txtLog = controls.txtLog; - ui.waveAxes = controls.waveAxes; - ui.noiseAxes = controls.noiseAxes; - ui.snrAxes = controls.snrAxes; - ui.templateAxes = controls.templateAxes; + "recordingChosen", @onRecordingChosen, ... + "clearRecording", @(~, ~) onClearRecording(), ... + "previewHeader", @(~, ~) onPreviewHeader(), ... + "importOptionChanged", @(~, ~) onImportOptionChanged(), ... + "refreshImport", @(~, ~) onRefreshImport(), ... + "channelChanged", @(~, ~) onChannelChanged(), ... + "analyze", @(~, ~) onAnalyze(), ... + "exportSegments", @(~, ~) onExportSegments(), ... + "exportWaveform", @(~, ~) onExportWaveform(), ... + "refreshPlots", @(~, ~) refreshPlots()); + spec = ecg_print.ui.buildSpec(callbacks); + ui = labkit.ui.app.create(spec, "debug", debugLog); + fig = ui.figure; + + txtFile = ui.controls.recording.status; + txtImportStatus = ui.controls.importStatus.valueHandle; + edtHeaderLine = ui.controls.headerLine.valueHandle; + ddHasHeader = ui.controls.hasHeader.valueHandle; + edtTimeColumn = ui.controls.timeColumn.valueHandle; + ddTimeUnit = ui.controls.timeUnit.valueHandle; + edtSignalColumns = ui.controls.signalColumns.valueHandle; + edtFallbackFs = ui.controls.fallbackFs.valueHandle; + ddChannel = ui.controls.channel.valueHandle; + edtStart = ui.controls.roiStart.valueHandle; + edtEnd = ui.controls.roiEnd.valueHandle; + edtLow = ui.controls.lowCut.valueHandle; + edtHigh = ui.controls.highCut.valueHandle; + ddPeakMethod = ui.controls.peakMethod.valueHandle; + edtPeakDist = ui.controls.peakDistance.valueHandle; + edtWin = ui.controls.segmentWindow.valueHandle; + edtTopN = ui.controls.templateTopN.valueHandle; + edtSmooth = ui.controls.smoothBeats.valueHandle; + ddTemplateView = ui.controls.templateView.valueHandle; + summaryTable = ui.controls.summaryTable.table; + txtFilePreview = ui.controls.filePreview.textArea; + ui.waveAxes = ui.controls.previewAxes.axesById.wave; + ui.noiseAxes = ui.controls.previewAxes.axesById.noise; + ui.snrAxes = ui.controls.previewAxes.axesById.snr; + ui.templateAxes = ui.controls.previewAxes.axesById.template; if debugLog.enabled - debugLog.attachTextLog(txtLog); debugLog.trace('ECG print debug trace enabled.'); - debugLog.instrumentFigure(fig); end resetAxes(); - function onOpenRecording(~, ~) - [fn, fp] = uigetfile( ... - {'*.mat;*.csv;*.txt;*.tsv', 'Biosignal files (*.mat, *.csv, *.txt, *.tsv)'; ... - '*.*', 'All files'}, ... - 'Select biosignal recording'); - if isequal(fn, 0) + function onRecordingChosen(~, event) + if isempty(event.paths) addLog('Recording selection cancelled.'); return; end - S.filepath = string(fullfile(fp, fn)); + S.filepath = string(event.paths{1}); txtFile.Value = char(S.filepath); clearParsedRecording(); updateFilePreview(); refreshImportParsing(false); end - function onRefreshImport(~, ~) + function onClearRecording() + S.filepath = ""; + txtFile.Value = 'No file loaded'; + txtFilePreview.Value = {'Open a CSV/text file, then use Preview file header.'}; + txtImportStatus.Value = 'Open a recording to inspect import settings.'; + clearParsedRecording(); + addLog('Cleared recording.'); + end + + function onRefreshImport() refreshImportParsing(true); end @@ -162,7 +148,7 @@ function refreshImportParsing(showAlertOnFailure) addLog(sprintf('Parsed %d channel(s) from %s', numel(channels), char(S.filepath))); end - function onPreviewHeader(~, ~) + function onPreviewHeader() updateFilePreview(); end @@ -175,7 +161,7 @@ function updateFilePreview() addLog(sprintf('Previewed file header: %s', char(S.filepath))); end - function onImportOptionChanged(~, ~) + function onImportOptionChanged() if strlength(S.filepath) > 0 txtImportStatus.Value = 'Import settings changed. Click Parse / refresh file.'; end @@ -198,7 +184,7 @@ function clearParsedRecording() refreshPlots(); end - function onChannelChanged(~, ~) + function onChannelChanged() if isempty(S.recording) || strcmp(ddChannel.Value, '(none)') return; end @@ -221,7 +207,7 @@ function setCurrentChannel(channelName) refreshPlots(); end - function onAnalyze(~, ~) + function onAnalyze() if isempty(S.signal) showError('No channel selected', 'Open a recording and select a channel first.'); return; @@ -258,7 +244,7 @@ function onAnalyze(~, ~) end end - function onExportSegments(~, ~) + function onExportSegments() if isempty(S.measurements) || isempty(S.measurements.perSegment) showError('No segment SNR', 'Analyze a signal before exporting segment SNR.'); return; @@ -273,7 +259,7 @@ function onExportSegments(~, ~) addLog(sprintf('Exported segment SNR CSV: %s', fullfile(fp, fn))); end - function onExportWaveform(~, ~) + function onExportWaveform() [fn, fp] = uiputfile('ecg_waveform.png', 'Export waveform PNG'); if isequal(fn, 0) addLog('Waveform export cancelled.'); @@ -346,7 +332,8 @@ function updateSummary() function refreshTemplatePlot() ax = ui.templateAxes; - labkit.ui.view.draw(ax, 'reset', 'Template + Residual Band'); + labkit.ui.view.resetAxes(ui, 'previewAxes', ... + 'Template + Residual Band', true, 'template'); xlabel(ax, 'Time from peak (s)'); ylabel(ax, 'Amplitude'); request = ecg_print.view.templatePlotRequest( ... @@ -396,22 +383,26 @@ function shadeMeasurementWindows(ax, request) end function resetAxes() - labkit.ui.view.draw(ui.waveAxes, 'reset', 'Waveform + Peaks'); + labkit.ui.view.resetAxes(ui, 'previewAxes', ... + 'Waveform + Peaks', true, 'wave'); xlabel(ui.waveAxes, 'Time (s)'); ylabel(ui.waveAxes, 'Amplitude'); - labkit.ui.view.draw(ui.noiseAxes, 'reset', 'Template Noise RMS Over Time'); + labkit.ui.view.resetAxes(ui, 'previewAxes', ... + 'Template Noise RMS Over Time', true, 'noise'); xlabel(ui.noiseAxes, 'Time (s)'); ylabel(ui.noiseAxes, 'Noise RMS'); - labkit.ui.view.draw(ui.snrAxes, 'reset', 'Template SNR Over Time'); + labkit.ui.view.resetAxes(ui, 'previewAxes', ... + 'Template SNR Over Time', true, 'snr'); xlabel(ui.snrAxes, 'Time (s)'); ylabel(ui.snrAxes, 'SNR (dB)'); - labkit.ui.view.draw(ui.templateAxes, 'reset', 'Template + Residual Band'); + labkit.ui.view.resetAxes(ui, 'previewAxes', ... + 'Template + Residual Band', true, 'template'); xlabel(ui.templateAxes, 'Time from peak (s)'); ylabel(ui.templateAxes, 'Amplitude'); end function addLog(message) - labkit.ui.view.update(txtLog, 'appendLog', message); + labkit.ui.view.appendLog(ui, 'appLog', message); debugLog.append(message); end diff --git a/apps/wearable/ecg_print/labkit_ECGPrint_app.m b/apps/wearable/ecg_print/labkit_ECGPrint_app.m index f885835..3d4a435 100644 --- a/apps/wearable/ecg_print/labkit_ECGPrint_app.m +++ b/apps/wearable/ecg_print/labkit_ECGPrint_app.m @@ -17,7 +17,7 @@ 'labkit_ECGPrint_app returns at most the app figure handle.'); end - fig = ecg_print.ui.runApp(debugLog); + fig = ecg_print.run(debugLog); if nargout >= 1 varargout{1} = fig; end diff --git a/docs/apps.md b/docs/apps.md index a26af43..547c5a4 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -83,7 +83,12 @@ The app owns: - failed-row behavior - callback ordering, alerts, and log wording -Every public app entry point should preserve its launch name, route debug launch requests through `labkit.ui.app.dispatchRequest`, build the GUI with `labkit.ui.app.createShell`, and keep visible debug trace wired into the Log tab during debug launches. Image apps with drawing, scale bars, ROI, or preview scroll should pass a `labkit.ui.tool.createRuntime` result into reusable tools instead of owning figure pointer callbacks directly. +Every public app entry point should preserve its launch name and route debug +launch requests through `labkit.ui.app.dispatchRequest`. App GUIs build from +`labkit.ui.app.create` and `labkit.ui.spec.*`. Debug launches should keep +visible debug trace wired into the Log tab. Image apps with drawing, scale +bars, ROI, or preview scroll should pass a `labkit.ui.tool.createRuntime` +result into reusable tools instead of owning figure pointer callbacks directly. When a documented UI tool owns app-neutral interaction mechanics, the app should consume that tool and keep workflow meaning, summaries, and exports app-local. `docs/architecture.md` owns the reusable-library extraction rule and temporary debt inventory. @@ -108,6 +113,14 @@ Create component packages only when the app has code for that responsibility. Use the app slug package name, not a fixed `+app` namespace, so MATLAB package resolution cannot mix helpers from sibling apps. +For UI 2.0 migrated apps, put the data-only workbench spec in +`+/+ui/buildSpec.m` and keep ordinary controls declarative. The public +entry point, or the app-owned orchestration runner it delegates to when the +public file is a thin dispatch wrapper, owns state, callbacks, alerts, log +wording, and refresh order. That orchestration source should call +`.ui.buildSpec(...)` followed by `labkit.ui.app.create(...)`. +`docs/architecture.md` owns the detailed component package role boundaries. + A typical single-file order before extraction is: ```text @@ -163,7 +176,11 @@ Define these before adding controls or helpers: 10. GUI shell spec, debug trace behavior, and file-selection mode ``` -Start from the closest existing app, reduce it to the needed workflow, and preserve ownership boundaries. Prefer `labkit.ui.app.createShell` even for small apps so daily interaction stays consistent across app families. +Start from the closest existing app, reduce it to the needed workflow, and +preserve ownership boundaries. For new app UI, prefer +`labkit.ui.app.create` with `labkit.ui.spec.*` even for small apps so daily +interaction stays consistent across app families. Do not copy old manual +layout into new code. ## Validation diff --git a/docs/architecture.md b/docs/architecture.md index 476bdad..4582151 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ apps/ category folders containing public app entry points or app subfolders Short version: ```text -labkit.ui layered GUI foundation split into app/view/tool/diag facades +labkit.ui layered GUI foundation split into app/spec/view/tool/diag facades labkit.dta current electrochemistry/Gamry DTA file and session facade labkit.biosignal current wearable/physiological time-series facade apps/ experiment-specific workflow apps @@ -60,7 +60,7 @@ repository does not track `LabKit.prj` or `resources/project/`. | Area | Responsibility | | --- | --- | | `apps/` | Public app entry points and app-specific workflow code, including app-owned package helpers under the owning app folder. | -| `+labkit/+ui` | Reusable GUI app/view/tool/diagnostics facades plus private implementation helpers. | +| `+labkit/+ui` | Reusable GUI app/spec/view/tool/diagnostics facades plus private implementation helpers. | | `+labkit/+dta` | GUI-free DTA discovery, loading, session, pulse, and parsed curve/table facade. | | `+labkit/+biosignal` | GUI-free recording loading, channel extraction, waveform processing, events, segments, templates, measurements, and group comparisons. | | `private/` helpers | Parser, normalization, item/session construction, pulse, and implementation details hidden behind the owning facade. | @@ -81,8 +81,9 @@ The app-facing UI API is intentionally layered: | Layer | Responsibility | App-facing API | | --- | --- | --- | -| App | Figure shell, tabs, request dispatch, busy state. | `labkit.ui.app.createShell`, `tab`, `dispatchRequest`, `runBusy`. | -| View | Sections, forms, reusable panels, axes, rendering actions, and UI state updates. | `labkit.ui.view.section`, `form`, `panel`, `axes`, `draw`, `update`, `place`. | +| App | Declarative app creation, request dispatch, busy state. | `labkit.ui.app.create`, `dispatchRequest`, `runBusy`. | +| Spec | Data-only UI 2.0 workbench specs. | `labkit.ui.spec.app`, `workspace`, `tab`, `section`, `field`, `rangeField`, `action`, `actionGroup`, `pathPanel`, `previewArea`, `resultTable`, `logPanel`, `statusPanel`, `custom`. | +| View | Semantic UI 2.0 registry updates and preview rendering helpers. | `labkit.ui.view.setValue`, `getValue`, `setEnabled`, `appendLog`, `setListItems`, `setListSelection`, `drawImage`, `resetAxes`, `clearAxes`. | | Tool | Exclusive interaction runtime and composed tools. | `labkit.ui.tool.createRuntime`, `anchorEditor`, `scaleBar`, `scaleBarCalibration`. | | Diagnostics | Debug launch, visible trace, callback instrumentation. | `labkit.ui.diag.createContext`. | @@ -100,6 +101,45 @@ Current image-measurement, electrochemistry, wearable, and DIC apps already follow the app-owned package shape. Do not copy older family-level `private/` helper layouts into new app work. +### App-Owned Package Shape + +UI 2.0 migrations use role-based app packages. The standard is not that every +app owns every package; it is that a file lives under the package matching the +role it actually performs: + +```text +/+ui/buildSpec.m data-only UI 2.0 workbench spec +/+ui/build*.m justified custom/tool UI builders only +/+state/*.m state factories, defaults, and presets +/+io/*.m file discovery, filters, readers, import parsing +/+ops/*.m GUI-free calculations and transforms +/+view/*.m tables, detail lines, display names, preview data +/+export/*.m output writers, manifests, summary tables +``` + +For a migrated UI 2.0 app, the public `labkit__app.m` entry point or +the app-owned orchestration runner it delegates to owns launch/debug routing, +app state, callback closures, alerts, log wording, and refresh order. That +orchestration source should call `.ui.buildSpec(...)` and +`labkit.ui.app.create(...)` rather than hand-writing ordinary layout. Keeping +nested callbacks in the runner is acceptable when they need closure access to +app state and UI registry handles. + +`+ui/buildSpec.m` returns only a data-only `labkit.ui.spec.*` tree. It may read +initial labels, defaults, callback handles, filters, and initial display data +from app-owned helpers, but it must not create MATLAB UI handles, call +`labkit.ui.app.create`, mutate app state, perform IO, run computations, write +exports, or set row/column layout mechanics. Custom UI belongs in a named +`+ui/build.m` file only when an interaction cannot be represented by the +ordinary spec grammar. + +File names should describe stable roles or outputs, not temporary +implementation buckets. Avoid names such as `helpers.m`, `utils.m`, `common.m`, +`misc.m`, `callbacks.m`, `manager.m`, `processor.m`, `layout.m`, and +`createUI.m`; prefer names such as `buildSpec.m`, `resultTableData.m`, +`detailLines.m`, `readImages.m`, `computeFocusStack.m`, `buildManifest.m`, or +`emptyResult.m`. + ## Current Temporary Debt Inventory This inventory is a narrow exception list, not a preferred design. It should @@ -165,14 +205,15 @@ Private helpers may keep shorter comments, but should still identify expected ca ## Validation Boundary -The default automated validation boundary is the non-GUI MATLAB build task: project architecture checks, `labkit` facade/parser checks, and pure app analysis/export checks. GitHub Actions runs that task on pushes and pull requests to `main`. +The default automated validation boundary is the non-GUI MATLAB build task: project architecture checks, `labkit` facade/parser checks, and pure app analysis/export checks. GitHub Actions runs that task on pushes and pull requests for every branch. GUI launch/layout checks live in source-aligned build tasks such as `testLabkitUiGui` and `testAppsGui`. Interactive GUI workflows are validated manually in MATLAB app windows. See `docs/testing.md` for the canonical validation matrix. ## Current Package Surface -- `labkit.ui.app`: shell specs, tab specs, internal request dispatch, and busy-state feedback. -- `labkit.ui.view`: sections, unified form controls, file panels, logs, tables, listbox state, axes reset/popout, image display, and prepared-X/Y plotting. +- `labkit.ui.app`: declarative app creation, legacy shell specs, tab specs, internal request dispatch, and busy-state feedback. +- `labkit.ui.spec`: data-only UI 2.0 workbench specs for tabs, sections, fields, actions, path panels, previews, results, logs, status, and custom tool slots. +- `labkit.ui.view`: semantic UI 2.0 state helpers, sections, unified form controls, file panels, logs, tables, listbox state, axes reset/popout, image display, and prepared-X/Y plotting. - `labkit.ui.tool`: interaction runtime, anchor editing, scale-bar tool, and scale-bar calibration. - `labkit.ui.diag`: debug context, visible trace, callback instrumentation, and log mirroring. - `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. diff --git a/docs/testing.md b/docs/testing.md index 464f896..d3fba0e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -108,12 +108,20 @@ commands. | Manual GUI validation | User-run app windows | Interactive file selection, drawing, visual inspection, and full workflow feel. | CI runs shell-wrapper, quality, unit, and integration jobs on pushes and pull -requests to `main` through `.github/workflows/matlab-tests.yml`. Manual and +requests for every branch through `.github/workflows/matlab-tests.yml`. Manual and scheduled CI runs also execute coverage, GUI structural, and non-blocking GUI gesture jobs. Coverage is intentionally outside the default PR gate to keep PR feedback focused and avoid duplicate test execution. Do not describe CI as full interactive GUI workflow validation. +Each MATLAB CI job writes a GitHub Step Summary with JUnit totals, artifact +locations, the slowest test cases, and failed-test details when available. +Failure summaries also include a compact MATLAB log tail so common failures can +be inspected from the Actions page. MATLAB HTML reports remain uploaded as +artifacts; GitHub Actions does not render artifact HTML inline, so interactive +HTML browsing still requires downloading the artifact or adding a separate +publishing target. + The shell-wrapper job owns repository-level checks that are cheaper and safer outside MATLAB, including the rule that `LabKit.prj` and `resources/project/` must stay untracked local IDE metadata. MATLAB build tasks should not shell out @@ -178,7 +186,7 @@ UI framework changes should cover the affected layer rather than only the change | UI layer | Automated coverage | | --- | --- | -| Public surface | `testProject` checks the layered `labkit.ui.app/view/tool/diag` API and private implementation packages. | +| Public surface | `testProject` checks the layered `labkit.ui.app/spec/view/tool/diag` API and private implementation packages. | | Shell/layout | `testLabkitUiGui` and affected app-family GUI tasks. | | Runtime/tools | `testLabkitUiGui` runtime, anchor-editor, and scale-bar tool tests. | | Diagnostics | `testLabkitUiGui` debug instrumentation tests plus `testAppsSmokeGui` debug launch trace checks. | diff --git a/docs/ui.md b/docs/ui.md index c7cca76..8f18f47 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1,118 +1,145 @@ # UI Library -`labkit.ui` is the reusable MATLAB GUI foundation. It is now split into four app-facing facade packages: +`labkit.ui` is the reusable MATLAB GUI foundation. It is split into app-facing facade packages: | Facade | Owns | Main APIs | | --- | --- | --- | -| `labkit.ui.app` | Figure shell, tabs, request dispatch, busy state. | `createShell`, `tab`, `dispatchRequest`, `runBusy`. | -| `labkit.ui.view` | Sections, forms, component panels, axes rendering, and app-neutral UI state updates. | `section`, `form`, `panel`, `axes`, `draw`, `update`, `place`. | +| `labkit.ui.app` | Declarative app creation, request dispatch, busy state. | `create`, `dispatchRequest`, `runBusy`. | +| `labkit.ui.spec` | UI 2.0 data-only workbench specs. | `app`, `workspace`, `tab`, `section`, `field`, `rangeField`, `action`, `actionGroup`, `pathPanel`, `previewArea`, `resultTable`, `logPanel`, `statusPanel`, `custom`. | +| `labkit.ui.view` | Semantic UI 2.0 registry updates and preview rendering helpers. | `setValue`, `getValue`, `setEnabled`, `appendLog`, `setListItems`, `setListSelection`, `drawImage`, `resetAxes`, `clearAxes`. | | `labkit.ui.tool` | Reusable composed image tools and interaction runtime. | `createRuntime`, `anchorEditor`, `scaleBar`, `scaleBarCalibration`. | | `labkit.ui.diag` | Debug launch context, visible trace, callback instrumentation. | `createContext`. | The root `labkit.ui.*` flat helper surface has been removed. Apps should call the facade that owns the behavior they need. Private implementation details live under each facade's `private/` folder. -## Standard Shell +## UI 2.0 Declarative Workbench -Every app should start from `labkit.ui.app.createShell`: +The UI 2.0 surface makes app code read as a semantic description +of a LabKit workbench workflow, not as grid construction or a general MATLAB GUI +DSL. App UI structure should be expressed through app-local +`+/+ui/buildSpec.m` files and created through `labkit.ui.app.create`. ```matlab -opts = struct( ... - 'rightTitle', 'Preview', ... - 'rightGridSize', [1 1], ... - 'rightRowHeight', {{'1x'}}); -ui = labkit.ui.app.createShell(struct( ... - 'title', 'Example App', ... - 'position', [90 70 1200 800], ... - 'leftWidth', 380, ... - 'options', opts)); -``` - -Default left tabs are: - -```text -Files + Analysis -Summary + Results -Log -``` - -Custom tabs use `labkit.ui.app.tab`: - -```matlab -opts.tabs = labkit.ui.app.tab( ... - 'filesAnalysis', 'Files + Analysis', [4 1], ... - {180, 220, 260, 140}); -``` - -The shell owns split panes, scrollable tab grids, row resize handles, and the right-side grid. Multi-row tabs get resize handles between adjacent logical rows by default. Use `struct('resize','none')` only for tabs whose rows should remain fixed. Apps own the controls and axes placed inside returned grids. - -## Views And Forms - -Use `labkit.ui.view.section` for titled app-defined sections: - -```matlab -section = labkit.ui.view.section(layFA, 'Analysis Settings', 2, [3 2]); -grid = section.grid; -``` - -Use `labkit.ui.view.form` as the single public control entry point. It replaces separate labeled spinner/dropdown/edit/read-only helpers: - -```matlab -[lblMode, ddMode] = labkit.ui.view.form(grid, struct( ... - 'kind', 'dropdown', ... - 'label', 'Mode:', ... - 'items', {{'Auto', 'Manual'}}, ... - 'value', 'Auto', ... - 'callback', @onModeChanged)); - -[lblN, edN] = labkit.ui.view.form(grid, struct( ... - 'kind', 'spinner', ... - 'label', 'Samples:', ... - 'value', 10, ... - 'limits', [1 Inf], ... - 'step', 1)); - -txtStatus = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', 'No file loaded')); - -txtMetric = labkit.ui.view.form(grid, struct( ... - 'kind', 'info', ... - 'row', 3, ... - 'label', 'Current value:')); -``` - -`form` also accepts a section spec with `title`, `row`, `layout`, and `controls`. The returned struct exposes `controls`, `labels`, `setValue(id,value,reason)`, and `getValue(id)`. `setValue` no-ops for unchanged values and suppresses app-facing semantic callbacks for internal/programmatic updates. - -When manually placing a component in a shell tab grid, use `labkit.ui.view.place(component, parentGrid, logicalRow)`. App code should not depend on physical row indices inserted by row-resize handles. - -Use `labkit.ui.view.panel` for reusable component groups such as file panels, log panels, read-only text panels, and result tables: - -```matlab -fileUi = labkit.ui.view.panel(layFA, 'files', labels, callbacks); -logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); -tableUi = labkit.ui.view.panel(laySR, 'table', 'Batch Results', 2, columns); +function varargout = labkit_Example_app(varargin) +[handled, outputs, debug] = labkit.ui.app.dispatchRequest( ... + "labkit_Example_app", varargin, nargout); +if handled + varargout = outputs; + return; +end + +spec = labkit.ui.spec.app("exampleApp", "Example App", ... + "controlTabs", { ... + labkit.ui.spec.tab("setup", "Setup", { ... + labkit.ui.spec.section("inputs", "Inputs", { ... + labkit.ui.spec.pathPanel("sourceImages", "Source images", ... + "mode", "multiFile", ... + "selectionMode", "single", ... + "filters", {{"*.png;*.tif;*.jpg", "Images"}}, ... + "status", "No images loaded", ... + "onChoose", @onChooseImages), ... + labkit.ui.spec.field("blendRadius", "Blend radius", ... + "kind", "slider", ... + "limits", [0 50], ... + "value", 12, ... + "unit", "px", ... + "onChange", @onBlendRadiusChanged), ... + labkit.ui.spec.rangeField("displayLimits", ... + "Display limits", ... + "limits", [0 1], ... + "value", [0 1], ... + "onChange", @onDisplayLimitsChanged), ... + labkit.ui.spec.actionGroup("runActions", { ... + labkit.ui.spec.action("run", "Run", @onRun, ... + "priority", "primary"), ... + labkit.ui.spec.action("reset", "Reset", @onReset)})})}), ... + labkit.ui.spec.tab("review", "Review", { ... + labkit.ui.spec.resultTable("results", "Results", ... + "columns", {"Name", "Status", "Score"}), ... + labkit.ui.spec.statusPanel("status", "Status")}), ... + labkit.ui.spec.tab("log", "Log", { ... + labkit.ui.spec.logPanel("log", "Log")})}, ... + "workspace", labkit.ui.spec.workspace("workspace", "Preview", { ... + labkit.ui.spec.previewArea("preview", "Preview", ... + "layout", "pair", ... + "viewModes", {"Input", "Fused", "Difference"}, ... + "onModeChange", @onPreviewModeChanged)})); + +ui = labkit.ui.app.create(spec, "debug", debug); +labkit.ui.view.setEnabled(ui, "run", false); +labkit.ui.view.appendLog(ui, "Ready."); + +if nargout >= 1 + varargout{1} = ui.figure; +end +end ``` -Use `labkit.ui.view.update` for state changes on existing component handles: +The fixed shape behind this sketch is: + +- The default app shell remains a LabKit workbench: left control tabs plus a + right workspace for primary preview, plotting, waveform, image, or canvas + content. +- `controlTabs`, `sections`, workspace children, and slots use cell arrays of + scalar spec structs for heterogeneous children. +- Control ids are globally unique within the app spec. `ui.controls.run` and + `ui.controls.sourceImages` are primary registry paths regardless of tab or + section placement. +- Public specs express stable app shapes: `pathPanel`, `field`, `rangeField`, + `action`, `actionGroup`, `previewArea`, `resultTable`, `logPanel`, and + `statusPanel`. Primitive controls such as button, dropdown, slider, listbox, + textarea, and axes are internal implementation details, not public spec + constructors. +- `pathPanel` separates chooser mode from list-selection behavior. A workflow + can load multiple files while keeping one current selection by using + `mode="multiFile"` with `selectionMode="single"`. +- `pathPanel` owns generic chooser/list/status mechanics while apps own command + wording. Use `chooseLabel` when the default `Choose files` or `Choose folder` + text is not the app's user-facing action label, and use `clearLabel` when + the clear action needs app-specific wording such as `Clear all`. +- `field` uses a fixed kind whitelist: `text`, `number`, `spinner`, `dropdown`, + `slider`, `checkbox`, and `readonly`. +- Public callbacks use `function callback(control, event)`, where `event` + carries semantic fields such as `id`, `kind`, `source`, `value`, + `previousValue`, and `ui`. +- `previewArea` belongs in `workspace` by default. Its optional `viewModes` + selector is also workspace-owned; apps can react through + `onModeChange`. Put preview-like content in a left tab only when it is + intentionally a compact control-pane preview. +- `previewArea` can take `axisIds`, `axisTitles`, `xLabels`, and `yLabels` so + plot and waveform apps keep app-authored axis wording without owning axes + layout mechanics. +- App-specific hand-written layout must go through `labkit.ui.spec.custom` and + a named builder file, for example: ```matlab -labkit.ui.view.update(logUi.textArea, 'appendLog', 'Loaded file.'); -[value, idx] = labkit.ui.view.update(fileUi.listbox, ... - 'listSelection', names, previousSelection); +labkit.ui.spec.custom("roiEditor", @example.ui.buildRoiEditor, ... + "height", "flex") ``` -## Axes And Rendering +`buildRoiEditor.m` may hand-write layout for that custom tool only. The app +runner, callbacks, and ordinary control specs should not create grids or set +`Layout.Row`/`Layout.Column` directly. -Use view helpers for app-neutral rendering boilerplate: +## View Helpers ```matlab -ax = labkit.ui.view.axes(parent, 1, 'Preview', 'X', 'Y'); -labkit.ui.view.draw(ax, 'reset', 'Preview', true); -hImage = labkit.ui.view.draw(ax, 'image', imageData, 'Reference'); -labkit.ui.view.draw(ax, 'popout'); +labkit.ui.view.setValue(ui, "displayLimits", [0.1 0.9]); +labkit.ui.view.setEnabled(ui, "run", false); +labkit.ui.view.setListItems(ui, "sourceImages", imageNames); +labkit.ui.view.setListSelection(ui, "sourceImages", imageNames, currentName); +labkit.ui.view.appendLog(ui, "log", "Loaded image."); +labkit.ui.view.drawImage(ui, "preview", imageData, ... + "axis", "raw", "title", "Reference"); +labkit.ui.view.resetAxes(ui, "preview", "Reference", true, "raw"); +labkit.ui.view.clearAxes(ui, "preview", "difference"); ``` -`draw(..., 'popout')` installs the standard right-click action `Open axes in new figure` and attaches it to axes children such as images and plotted lines. Apps should call it after custom redraws that create new graphics children. +View helpers target semantic ids in the UI registry returned by +`labkit.ui.app.create`. They do not create arbitrary controls or expose MATLAB +layout primitives. `previewArea` axes automatically receive the standard +right-click action `Open axes in new figure`; apps redraw prepared data through +the named preview helpers. ## Interaction Tools @@ -150,7 +177,11 @@ Debug launches support: [fig, debug] = appName("--debug", opts); ``` -App-local `addLog` functions should append to the visible UI log with `labkit.ui.view.update(txtLog, 'appendLog', message)` and then call `debug.append(message)`. Debug-mode apps attach the Log tab text area, emit a startup trace line, pass `debug.trace` into reusable tools through `onTrace`, and call `debug.instrumentFigure(fig)` after controls are built. +Apps append visible log lines through `labkit.ui.view.appendLog(ui, "log", +message)` or the app's chosen log-panel id, then call `debug.append(message)`. +Debug-mode apps attach the Log tab text area, emit a startup trace line, pass +`debug.trace` into reusable tools through `onTrace`, and call +`debug.instrumentFigure(fig)` after controls are built. Trace lines include timestamp plus stable `app=...`, `component=...`, `event=...`, and `reason=...` fields. Default instrumentation skips low-level pointer, drag, and scroll callbacks. diff --git a/labkit_launcher.m b/labkit_launcher.m index 2e0025c..e79b999 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -69,130 +69,54 @@ function initializePath(root) end function fig = createLauncherFigure(root, apps) - workbenchOpts = struct( ... - 'rightTitle', 'Applications', ... - 'rightGridSize', [1 1], ... - 'rightRowHeight', {{'1x'}}); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Find App', [3 1], ... - {125, 245, '1x'}, ... - struct('resizeOptions', struct('minTopHeight', 150, 'minBottomHeight', 100))), ... - labkit.ui.app.tab('log', 'Status', [1 1], {'1x'})]; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'LabKit App Launcher', ... + initialFamilyItems = familyFilterItems(apps); + spec = labkit.ui.spec.app('labkitLauncher', 'LabKit App Launcher', ... 'position', [100 80 1320 760], ... 'leftWidth', 390, ... - 'options', workbenchOpts)); - fig = ui.fig; + 'controlTabs', { ... + labkit.ui.spec.tab('findApp', 'Find App', { ... + labkit.ui.spec.section('filterSection', 'Filter', { ... + labkit.ui.spec.statusPanel('launcherSummary', ... + 'LabKit Apps', ... + 'value', {sprintf('%d app entry points found', numel(apps))}), ... + labkit.ui.spec.field('search', 'Search:', ... + 'kind', 'text', ... + 'onChange', @onFilterChanged), ... + labkit.ui.spec.field('family', 'Family:', ... + 'kind', 'dropdown', ... + 'items', cellstr(initialFamilyItems), ... + 'value', char(initialFamilyItems(1)), ... + 'onChange', @onFilterChanged)}), ... + labkit.ui.spec.section('selectedAppSection', 'Selected App', { ... + labkit.ui.spec.statusPanel('selectedDetails', ... + 'Selected App', ... + 'value', {'No app selected.'}), ... + labkit.ui.spec.action('openSelected', ... + 'Open Selected App', @onLaunch)}), ... + labkit.ui.spec.section('actionsSection', 'Actions', { ... + labkit.ui.spec.action('refreshApps', ... + 'Refresh App List', @onRefresh), ... + labkit.ui.spec.statusPanel('launcherHint', ... + 'Hint', ... + 'value', {'Select a row to inspect it. Double-click a row to open that app.'})})}), ... + labkit.ui.spec.tab('statusTab', 'Status', { ... + labkit.ui.spec.section('statusSection', 'Status', { ... + labkit.ui.spec.logPanel('statusLog', 'Status', ... + 'value', {'Ready.'})})})}, ... + 'workspace', labkit.ui.spec.workspace('applicationsWorkspace', ... + 'Applications', { ... + labkit.ui.spec.resultTable('appTable', 'Applications', ... + 'columns', {'Family', 'App', 'Command'})})); + + ui = labkit.ui.app.create(spec); + fig = ui.figure; fig.Color = [0.97 0.98 0.99]; - layFind = ui.filesAnalysisGrid; - layLog = ui.logGrid; - - filterPanel = labkit.ui.view.section(layFind, 'Filter', 1, [4 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{92, '1x'}})); - filterGrid = filterPanel.grid; - - titleLabel = uilabel(filterGrid, ... - 'Text', 'LabKit Apps', ... - 'FontSize', 20, ... - 'FontWeight', 'bold'); - titleLabel.Layout.Row = 1; - titleLabel.Layout.Column = [1 2]; - - subtitleLabel = uilabel(filterGrid, ... - 'Text', sprintf('%d app entry points found', numel(apps)), ... - 'FontColor', [0.31 0.36 0.42]); - subtitleLabel.Layout.Row = 2; - subtitleLabel.Layout.Column = [1 2]; - - lblSearch = uilabel(filterGrid, 'Text', 'Search:'); - lblSearch.Layout.Row = 3; - lblSearch.Layout.Column = 1; - txtFilter = uieditfield(filterGrid, 'text', ... - 'Placeholder', 'Name, command, family, or path', ... - 'ValueChangedFcn', @onFilterChanged); - txtFilter.Layout.Row = 3; - txtFilter.Layout.Column = 2; - - lblFamily = uilabel(filterGrid, 'Text', 'Family:'); - lblFamily.Layout.Row = 4; - lblFamily.Layout.Column = 1; - familyItems = familyFilterItems(apps); - ddFamily = uidropdown(filterGrid, ... - 'Items', cellstr(familyItems), ... - 'Value', char(familyItems(1)), ... - 'ValueChangedFcn', @onFilterChanged); - ddFamily.Layout.Row = 4; - ddFamily.Layout.Column = 2; - - detailPanel = labkit.ui.view.section(layFind, 'Selected App', 2, [6 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', '1x', 'fit'}}, ... - 'columnWidth', {{76, '1x'}})); - detailGrid = detailPanel.grid; - - detailName = uilabel(detailGrid, ... - 'Text', '', ... - 'FontWeight', 'bold', ... - 'FontSize', 16); - detailName.Layout.Row = 1; - detailName.Layout.Column = [1 2]; - - lblDetailFamily = uilabel(detailGrid, 'Text', 'Family:'); - lblDetailFamily.Layout.Row = 2; - lblDetailFamily.Layout.Column = 1; - detailFamily = uilabel(detailGrid, 'Text', ''); - detailFamily.Layout.Row = 2; - detailFamily.Layout.Column = 2; - - lblDetailCommand = uilabel(detailGrid, 'Text', 'Command:'); - lblDetailCommand.Layout.Row = 3; - lblDetailCommand.Layout.Column = 1; - detailCommand = uilabel(detailGrid, ... - 'Text', '', ... - 'FontName', 'Monospaced'); - detailCommand.Layout.Row = 3; - detailCommand.Layout.Column = 2; - - lblDetailPath = uilabel(detailGrid, 'Text', 'Path:'); - lblDetailPath.Layout.Row = 4; - lblDetailPath.Layout.Column = 1; - detailPath = uilabel(detailGrid, ... - 'Text', '', ... - 'FontColor', [0.31 0.36 0.42], ... - 'Interpreter', 'none'); - detailPath.Layout.Row = 4; - detailPath.Layout.Column = 2; - - detailDescription = uitextarea(detailGrid, 'Editable', 'off'); - detailDescription.Layout.Row = 5; - detailDescription.Layout.Column = [1 2]; - - btnDetailLaunch = uibutton(detailGrid, ... - 'Text', 'Open Selected App', ... - 'ButtonPushedFcn', @onLaunch); - btnDetailLaunch.Layout.Row = 6; - btnDetailLaunch.Layout.Column = [1 2]; - - actionPanel = labkit.ui.view.section(layFind, 'Actions', 3, [2 1], ... - struct('rowHeight', {{'fit', 'fit'}})); - actionGrid = actionPanel.grid; - btnRefresh = uibutton(actionGrid, ... - 'Text', 'Refresh App List', ... - 'ButtonPushedFcn', @onRefresh); - btnRefresh.Layout.Row = 1; - hintLabel = uilabel(actionGrid, ... - 'Text', 'Select a row to inspect it. Double-click a row to open that app.', ... - 'FontColor', [0.31 0.36 0.42]); - hintLabel.Layout.Row = 2; - if isprop(hintLabel, 'WordWrap') - hintLabel.WordWrap = 'on'; - end - - tblApps = uitable(ui.rightGrid); - tblApps.Layout.Row = 1; - tblApps.ColumnName = {'Family', 'App', 'Command'}; + txtFilter = ui.controls.search.valueHandle; + ddFamily = ui.controls.family.valueHandle; + summaryText = ui.controls.launcherSummary.textArea; + detailText = ui.controls.selectedDetails.textArea; + btnDetailLaunch = ui.controls.openSelected.button; + tblApps = ui.controls.appTable.table; tblApps.ColumnEditable = [false false false]; tblApps.ColumnSortable = [true true true]; tblApps.RowName = {}; @@ -210,10 +134,7 @@ function initializePath(root) elseif isprop(tblApps, 'CellDoubleClickedFcn') tblApps.CellDoubleClickedFcn = @onTableDoubleClicked; end - statusLabel = uitextarea(layLog, ... - 'Editable', 'off', ... - 'Value', {'Ready.'}); - statusLabel.Layout.Row = 1; + statusLabel = ui.controls.statusLog.textArea; state = struct(); state.apps = apps; @@ -232,7 +153,7 @@ function onRefresh(~, ~) ddFamily.Items = cellstr(familyFilterItems(state.apps)); ddFamily.Value = ddFamily.Items{1}; state.selectedRow = 1; - subtitleLabel.Text = sprintf('%d app entry points found', numel(state.apps)); + summaryText.Value = {sprintf('%d app entry points found', numel(state.apps))}; refreshTable(); end @@ -290,22 +211,19 @@ function refreshTable() function refreshSelection() if isempty(state.visibleApps) - detailName.Text = 'No matching apps'; - detailFamily.Text = ''; - detailCommand.Text = ''; - detailPath.Text = ''; - detailDescription.Value = {'No app matches the current filters.'}; + detailText.Value = {'No matching apps'; 'No app matches the current filters.'}; clearTableStyles(); return; end row = min(max(state.selectedRow, 1), numel(state.visibleApps)); app = state.visibleApps(row); - detailName.Text = char(app.displayName); - detailFamily.Text = char(app.family); - detailCommand.Text = app.command; - detailPath.Text = app.relativePath; - detailDescription.Value = cellstr(wrapDescription(app.description)); + detailText.Value = [ ... + {char(app.displayName)}; ... + {['Family: ' char(app.family)]}; ... + {['Command: ' app.command]}; ... + {['Path: ' app.relativePath]}; ... + cellstr(wrapDescription(app.description))]; highlightSelectedRow(row); end diff --git a/scripts/summarize_junit.py b/scripts/summarize_junit.py index a2a22b0..929a16b 100644 --- a/scripts/summarize_junit.py +++ b/scripts/summarize_junit.py @@ -4,7 +4,8 @@ The script is intentionally dependency-free so CI failure summaries keep working before Python packages are installed. It never fails the job; MATLAB test steps own pass/fail status. This helper only surfaces failed testcase names, messages, -and a short MATLAB log tail in the GitHub job summary and annotations. +slow testcase hints, artifact locations, and a short MATLAB log tail in the +GitHub job summary and annotations. """ from __future__ import annotations @@ -26,17 +27,16 @@ def main() -> int: if not junit_path.is_file(): message = f"JUnit report not found: {junit_path}" - write_summary( - summary_path, - [ - f"### {run_name}", - "", - f"> ⚠️ {message}", - "", - artifact_lines(args), - "", - ], - ) + lines = [ + f"### {run_name}", + "", + f"> ⚠️ {message}", + "", + artifact_lines(args), + "", + ] + lines += log_tail_summary(args.log, args.summary_log_tail_lines) + write_summary(summary_path, lines) print_annotation("warning", f"{run_name} report missing", message) print_log_tail(args.log, args.log_tail_lines, "MATLAB log tail") return 0 @@ -45,11 +45,14 @@ def main() -> int: suites, failed_cases = parse_junit(junit_path) except Exception as exc: # pragma: no cover - defensive CI reporting path. message = f"Could not parse {junit_path}: {exc}" - write_summary(summary_path, [f"### {run_name}", "", f"> ⚠️ {message}", ""]) + lines = [f"### {run_name}", "", f"> ⚠️ {message}", ""] + lines += log_tail_summary(args.log, args.summary_log_tail_lines) + write_summary(summary_path, lines) print_annotation("warning", f"{run_name} report parse failed", message) print_log_tail(args.log, args.log_tail_lines, "MATLAB log tail") return 0 + test_cases = parse_test_cases(suites) totals = { "tests": sum(to_int(s.get("tests")) for s in suites), "failures": sum(to_int(s.get("failures")) for s in suites), @@ -89,6 +92,8 @@ def main() -> int: f"Showing first {args.max_failures} failures; inspect artifacts for the full report." ) lines.append("") + lines += failure_detail_lines(failed_cases, args.max_failure_details) + lines += log_tail_summary(args.log, args.summary_log_tail_lines) for case in failed_cases[: args.max_annotations]: print_annotation( @@ -100,6 +105,7 @@ def main() -> int: else: lines += ["No failed tests reported in JUnit.", ""] + lines += slow_test_lines(test_cases, args.max_slow_tests) write_summary(summary_path, lines) print( ( @@ -118,7 +124,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--log", default="", help="Path to the MATLAB log file.") parser.add_argument("--max-failures", type=int, default=20) parser.add_argument("--max-annotations", type=int, default=20) + parser.add_argument("--max-failure-details", type=int, default=5) + parser.add_argument("--max-slow-tests", type=int, default=5) parser.add_argument("--log-tail-lines", type=int, default=180) + parser.add_argument("--summary-log-tail-lines", type=int, default=80) return parser.parse_args() @@ -136,6 +145,7 @@ def parse_junit(junit_path: Path) -> tuple[list[ET.Element], list[dict[str, str] "classname": testcase.get("classname", ""), "name": testcase.get("name", ""), "message": compact_message(node.get("message") or node.text or ""), + "detail": compact_detail(node.text or node.get("message") or ""), "kind": tag, } ) @@ -143,16 +153,121 @@ def parse_junit(junit_path: Path) -> tuple[list[ET.Element], list[dict[str, str] return suites, failed_cases +def parse_test_cases(suites: list[ET.Element]) -> list[dict[str, str | float]]: + test_cases: list[dict[str, str | float]] = [] + for suite in suites: + suite_name = suite.get("name", "") + for testcase in suite.findall("testcase"): + test_cases.append( + { + "suite": suite_name, + "classname": testcase.get("classname", ""), + "name": testcase.get("name", ""), + "time": to_float(testcase.get("time")), + } + ) + return test_cases + + def artifact_lines(args: argparse.Namespace) -> str: rows = [] + url = github_artifacts_url() + if url: + rows.append(f"- Run artifacts page: [open artifacts for this run]({url})") if args.html: - rows.append(f"- HTML report: `{args.html}`") + rows.append( + "- HTML report in artifact: " + f"`{args.html}` (download the artifact; Actions does not render artifact HTML inline)" + ) if args.log: rows.append(f"- MATLAB log: `{args.log}`") rows.append(f"- JUnit XML: `{args.junit_xml}`") return "\n".join(rows) +def github_artifacts_url() -> str: + server = os.environ.get("GITHUB_SERVER_URL", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if not server or not repo or not run_id: + return "" + return f"{server.rstrip('/')}/{repo}/actions/runs/{run_id}#artifacts" + + +def failure_detail_lines( + failed_cases: list[dict[str, str]], max_failure_details: int +) -> list[str]: + if max_failure_details <= 0: + return [] + lines = [ + f"#### Failure details (first {min(len(failed_cases), max_failure_details)})", + "", + ] + for case in failed_cases[:max_failure_details]: + title = f"{case['classname']}.{case['name']}" + lines += [ + "
", + f"{markdown_escape(title)}", + "", + "```text", + fence_escape(case["detail"]), + "```", + "", + "
", + "", + ] + return lines + + +def slow_test_lines( + test_cases: list[dict[str, str | float]], max_slow_tests: int +) -> list[str]: + if max_slow_tests <= 0 or not test_cases: + return [] + slow_cases = sorted(test_cases, key=lambda case: float(case["time"]), reverse=True) + slow_cases = slow_cases[:max_slow_tests] + lines = [ + f"#### Slowest tests (top {len(slow_cases)})", + "", + "| Class | Test | time (s) |", + "|---|---|---:|", + ] + for case in slow_cases: + lines.append( + f"| `{case['classname']}` | `{case['name']}` | {float(case['time']):.2f} |" + ) + lines.append("") + return lines + + +def log_tail_summary(log_path: str, line_count: int) -> list[str]: + if not log_path or line_count <= 0: + return [] + path = Path(log_path) + if not path.is_file(): + return [ + "#### MATLAB log tail", + "", + f"> MATLAB log not found: `{path}`", + "", + ] + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + tail = lines[-line_count:] + return [ + f"#### MATLAB log tail (last {len(tail)} lines)", + "", + "
", + "Show MATLAB log tail", + "", + "```text", + fence_escape("\n".join(tail)), + "```", + "", + "
", + "", + ] + + def print_log_tail(log_path: str, line_count: int, title: str) -> None: if not log_path: return @@ -188,10 +303,24 @@ def compact_message(message: str) -> str: return message[:500] if message else "(no message)" +def compact_detail(message: str) -> str: + message = message.strip() + if not message: + return "(no detail)" + max_chars = 6000 + if len(message) <= max_chars: + return message + return message[:max_chars] + "\n... truncated ..." + + def markdown_escape(message: str) -> str: return message.replace("|", "\\|") +def fence_escape(message: str) -> str: + return message.replace("```", "` ` `") + + def escape_command(value: str) -> str: return ( value.replace("%", "%25") diff --git a/tests/AGENTS.md b/tests/AGENTS.md index d4cc21c..0587032 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -26,11 +26,11 @@ Tests mirror source ownership. Do not create a parallel runner framework unless migration creates an app-owned package for DIC or wearable apps, add unit tests that call non-UI package functions such as `+ops`, `+view`, `+export`, `+io`, or `+state` directly. -- Guardrails should also prevent app `+ui/runApp.m` files from keeping - same-named local helper copies once the behavior exists in the app-owned - package. Ordinary tests should call the package helper; GUI structural tests - only prove wiring/layout. -- UI public-surface tests should assert the layered `labkit.ui.app/view/tool/diag` facade and keep low-level controls, row resize, panel internals, and popout implementation private. +- Guardrails should prevent app lifecycle orchestration from living in + `+ui/runApp.m`; migrated apps use package-root `run.m` plus data-only + `+ui/buildSpec.m`. Ordinary tests should call package helpers directly; GUI + structural tests only prove wiring/layout. +- UI public-surface tests should assert the layered `labkit.ui.app/spec/view/tool/diag` facade and keep low-level controls, row resize, panel internals, and popout implementation private. - GUI smoke/debug tests may assert that every app supports debug launch and visible startup trace, but should not claim full interactive workflow validation. - When one test file grows too broad, add new focused `test_*.m` files instead of appending unrelated coverage. - GUI tests are structural launch/layout/callback checks; do not claim full interactive workflow validation from automated GUI tests. diff --git a/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m b/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m index f995faf..de9af2a 100644 --- a/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m +++ b/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m @@ -65,9 +65,9 @@ function checkCurvatureMeasurementLayout(h) function checkFocusStackLayout(h) fig = h.launchFigure('labkit_FocusStack_app', 'Microscope Focus Stack Fusion'); h.assertFigureMinimumSize(fig, 1440, 860); - h.assertComponentCounts(fig, struct('Button', 6, 'CheckBox', 1, ... + h.assertComponentCounts(fig, struct('Button', 7, 'CheckBox', 1, ... 'DropDown', 1, 'Spinner', 3, 'ListBox', 1, 'Table', 1, 'TextArea', 3, 'Axes', 2)); - h.assertButtonContract(fig, {'Open image folder', 'Open image files', ... + h.assertButtonContract(fig, {'Open image folder', 'Choose files', 'Clear', ... 'Run focus stack', 'Export fused PNG', 'Export focus map PNG', 'Export summary CSV'}); h.assertCheckboxContract(fig, {'Auto-register stack to middle image'}); h.assertDropdownGroups(fig, h.dropdownGroup({'Balanced', 'Crisp details', ... @@ -77,6 +77,21 @@ function checkFocusStackLayout(h) h.assertAxesContract(fig, { ... h.axesSpec('Fused all-in-focus image', '', ''), ... h.axesSpec('Focus-depth index map', '', '')}); + + h.closeAllFigures(); + [fig, debug] = labkit_FocusStack_app("debug", struct()); + drawnow; + assert(debug.enabled && debug.traceEnabled, ... + 'Focus Stack debug launch should return an enabled trace logger.'); + assertAnyTextAreaContains(h, fig, 'Focus stack debug trace enabled', ... + 'Focus Stack debug launch should mirror trace lines into the visible Log tab.'); + + h.invokeDropdownValue(fig, 'Crisp details'); + lines = string(debug.getLog()); + assert(any(contains(lines, 'BEGIN ValueChangedFcn')), ... + 'Focus Stack debug mode should instrument declarative control callbacks.'); + assertAnyTextAreaContains(h, fig, 'BEGIN ValueChangedFcn', ... + 'Focus Stack debug mode should mirror instrumented callback traces into the visible Log tab.'); end function checkBatchImageCropLayout(h) @@ -106,13 +121,15 @@ function checkBatchImageCropLayout(h) function checkImageEnhanceLayout(h) fig = h.launchFigure('labkit_ImageEnhance_app', 'Paper Image Enhance'); h.assertFigureMinimumSize(fig, 1460, 860); - h.assertComponentCounts(fig, struct('Button', 7, 'DropDown', 2, ... - 'Spinner', 2, 'ListBox', 2, 'Table', 2, 'TextArea', 2, 'Axes', 1)); - h.assertButtonContract(fig, {'Open image files', 'Clear images', ... + h.assertComponentCounts(fig, struct('Button', 7, 'DropDown', 3, ... + 'Spinner', 2, 'ListBox', 1, 'Table', 2, 'TextArea', 2, 'Axes', 1)); + h.assertButtonContract(fig, {'Choose files', 'Clear', ... 'Apply tool', 'Undo history', 'Reset history', ... 'Choose folder', 'Export enhanced images'}); h.assertDropdownGroups(fig, [ ... h.dropdownGroup({'Enhanced', 'Original', 'Before | After'}, 1), ... + h.dropdownGroup({'Brightness/contrast', 'Local contrast', 'Sharpen', ... + 'Hue/saturation', 'White balance'}, 1), ... h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1)]); h.assertTabTitles(fig, {'Library + Export', 'Tools + History', 'Log'}); h.assertAnyTableColumns(fig, {'Metric', 'Value'}); @@ -133,7 +150,7 @@ function checkImageMatchLayout(h) h.assertFigureMinimumSize(fig, 1460, 860); h.assertComponentCounts(fig, struct('Button', 7, 'DropDown', 4, ... 'Spinner', 3, 'ListBox', 1, 'Table', 2, 'TextArea', 3, 'Axes', 1)); - h.assertButtonContract(fig, {'Open image files', 'Clear images', ... + h.assertButtonContract(fig, {'Choose files', 'Clear', ... 'Apply match', 'Undo history', 'Reset history', ... 'Choose folder', 'Export matched images'}); h.assertDropdownGroups(fig, [ ... diff --git a/tests/gui/structural/apps/wearable/GuiLayoutWearableTest.m b/tests/gui/structural/apps/wearable/GuiLayoutWearableTest.m index d97b385..b4afc76 100644 --- a/tests/gui/structural/apps/wearable/GuiLayoutWearableTest.m +++ b/tests/gui/structural/apps/wearable/GuiLayoutWearableTest.m @@ -18,11 +18,11 @@ function verify_gui_layout_wearable() fig = h.launchFigure('labkit_ECGPrint_app', 'ECG Signal Print + SNR Explorer'); h.assertFigureMinimumSize(fig, 1480, 880); - h.assertComponentCounts(fig, struct('Button', 6, 'DropDown', 5, ... + h.assertComponentCounts(fig, struct('Button', 7, 'DropDown', 5, ... 'Table', 1, 'TextArea', 3, 'Axes', 4, 'Spinner', 10)); h.assertButtonContract(fig, {'Open recording', 'Analyze current ROI', ... 'Preview file header', 'Parse / refresh file', ... - 'Export segment SNR CSV', 'Export waveform PNG'}); + 'Export segment SNR CSV', 'Export waveform PNG', 'Clear recording'}); h.assertDropdownGroups(fig, [ ... h.dropdownGroup({'Auto', 'Yes', 'No'}, 1), ... h.dropdownGroup({'Auto', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds'}, 1), ... diff --git a/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m b/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m index 86fdc7b..43820e0 100644 --- a/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m +++ b/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m @@ -1,5 +1,5 @@ classdef GuiLayoutUiAxesWorkbenchTest < matlab.uitest.TestCase - %GUILAYOUTUIAXESWORKBENCHTEST Verify LabKit behavior through official MATLAB tests. + %GUILAYOUTUIAXESWORKBENCHTEST Verify UI 2.0 shell and axes behavior. methods (Test, TestTags = {'GUI', 'Structural'}) function test_gui_layout_ui_axes_workbench(testCase) @@ -10,139 +10,68 @@ function test_gui_layout_ui_axes_workbench(testCase) end function verify_gui_layout_ui_axes_workbench() -%TEST_GUI_LAYOUT_UI_AXES_WORKBENCH Verify axes, shell, and plot-control helpers. +%TEST_GUI_LAYOUT_UI_AXES_WORKBENCH Verify declarative workbench axes behavior. h = guiTestHelpers(); h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - checkCreateAxesHelper(h); - checkCreateAppShellHelper(h); -end - -function checkCreateAxesHelper(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_create_axes_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 1]); + spec = labkit.ui.spec.app('axesWorkbenchProbe', 'Axes Workbench Probe', ... + 'position', [40 30 1200 760], ... + 'leftWidth', 330, ... + 'controlTabs', { ... + labkit.ui.spec.tab('setup', 'Setup', { ... + labkit.ui.spec.section('inputs', 'Inputs', { ... + labkit.ui.spec.action('run', 'Run', @noop)})})}, ... + 'workspace', labkit.ui.spec.workspace('workspace', 'Preview', { ... + labkit.ui.spec.previewArea('plotPreview', 'Plots', ... + 'layout', 'stack', ... + 'axisIds', {'plot', 'image'}, ... + 'axisTitles', {'Probe Plot', 'Image Probe'}, ... + 'xLabels', {'Probe X', ''}, ... + 'yLabels', {'Probe Y', ''})})); + ui = labkit.ui.app.create(spec); - ax = labkit.ui.view.axes(grid, 2, 'Probe Title', 'Probe X', 'Probe Y'); - plot(ax, 1:3, [1 4 2], 'DisplayName', 'probe'); - labkit.ui.view.draw(ax, 'popout'); - assert(ax.Layout.Row == 2, 'Axes helper should set the requested layout row.'); - assert(strcmp(char(ax.Title.String), 'Probe Title'), 'Axes helper should preserve the title.'); - assert(strcmp(char(ax.XLabel.String), 'Probe X'), 'Axes helper should preserve the x label.'); - assert(strcmp(char(ax.YLabel.String), 'Probe Y'), 'Axes helper should preserve the y label.'); - h.assertAxesPopoutEnabled(ax, 'Axes helper should install the LabKit popout context action.'); + h.assertFigureMinimumSize(ui.figure, 1200, 760); + h.assertTabTitles(ui.figure, {'Setup', 'Preview', 'Inputs'}); + assert(isequal(ui.main.ColumnWidth, {330, 6, '1x'}), ... + 'UI 2.0 app builder should create the standard resizable workbench layout.'); + assert(strcmp(ui.rightPanel.Title, 'Preview'), ... + 'UI 2.0 app builder should preserve the workspace title.'); - menuItem = findall(ax.ContextMenu, 'Type', 'uimenu', 'Tag', 'labkitAxesPopoutMenu'); + plotAx = ui.controls.plotPreview.axesById.plot; + plot(plotAx, 1:3, [1 4 2], 'DisplayName', 'probe'); + h.assertAxesPopoutEnabled(plotAx, ... + 'UI 2.0 preview axes should install the LabKit popout context action.'); + menuItem = findall(plotAx.ContextMenu, 'Type', 'uimenu', ... + 'Tag', 'labkitAxesPopoutMenu'); h.invokeCallback(menuItem, 'MenuSelectedFcn'); drawnow; - popoutFig = findall(groot, 'Type', 'figure', 'Name', 'Probe Title'); - assert(~isempty(popoutFig), 'Axes popout menu should create a standalone figure.'); - popoutFig = popoutFig(1); - popoutCleaner = onCleanup(@() delete(popoutFig)); - popoutAxes = findobj(popoutFig, 'Type', 'axes'); - assert(numel(popoutAxes) >= 1, 'Axes popout should create an editable figure axes.'); - assert(strcmp(char(popoutAxes(1).Title.String), 'Probe Title'), ... - 'Axes popout should preserve the source title.'); - assert(~isempty(popoutAxes(1).Children), ... + popoutFig = findall(groot, 'Type', 'figure', 'Name', 'Probe Plot'); + assert(~isempty(popoutFig), 'Axes popout menu should create a standalone plot figure.'); + popoutCleaner = onCleanup(@() delete(popoutFig(1))); + popoutAxes = findobj(popoutFig(1), 'Type', 'axes'); + assert(numel(popoutAxes) >= 1 && ~isempty(popoutAxes(1).Children), ... 'Axes popout should copy plotted children.'); assert(strcmp(popoutAxes(1).DataAspectRatioMode, 'auto') && ... strcmp(popoutAxes(1).PlotBoxAspectRatioMode, 'auto'), ... - 'Axes popout should leave the copied plot with freely adjustable aspect ratio.'); - h.assertAxesChildrenUsePopoutMenu(ax, ... - 'Axes helper should attach the popout menu to plotted child objects.'); + 'Plot axes popout should keep copied plots freely resizable.'); - imgAx = labkit.ui.view.axes(grid, 1, 'Image Probe', '', ''); - hImage = labkit.ui.view.draw(imgAx, 'image', zeros(12, 16, 3, 'uint8'), 'Image Probe'); - assert(strcmp(char(imgAx.Title.String), 'Image Probe'), ... - 'Image axes helper should preserve the supplied title.'); - assert(isequal(hImage.ContextMenu, imgAx.ContextMenu), ... - 'Image axes helper should attach the popout menu to the image object.'); - imageMenuItem = findall(imgAx.ContextMenu, 'Type', 'uimenu', 'Tag', 'labkitAxesPopoutMenu'); + labkit.ui.view.drawImage(ui, 'plotPreview', ... + zeros(12, 16, 3, 'uint8'), 'axis', 'image', 'title', 'Image Probe'); + imgAx = ui.controls.plotPreview.axesById.image; + imageMenuItem = findall(imgAx.ContextMenu, 'Type', 'uimenu', ... + 'Tag', 'labkitAxesPopoutMenu'); h.invokeCallback(imageMenuItem, 'MenuSelectedFcn'); drawnow; imagePopoutFig = findall(groot, 'Type', 'figure', 'Name', 'Image Probe'); - assert(~isempty(imagePopoutFig), 'Image axes popout menu should create a standalone figure.'); - imagePopoutFig = imagePopoutFig(1); - imagePopoutCleaner = onCleanup(@() delete(imagePopoutFig)); - imagePopoutAxes = findobj(imagePopoutFig, 'Type', 'axes'); - assert(numel(imagePopoutAxes) >= 1, 'Image axes popout should create copied axes.'); + assert(~isempty(imagePopoutFig), 'Image axes popout should create a standalone figure.'); + imageCleaner = onCleanup(@() delete(imagePopoutFig(1))); + imagePopoutAxes = findobj(imagePopoutFig(1), 'Type', 'axes'); + assert(numel(imagePopoutAxes) >= 1, 'Image popout should create copied axes.'); assert(strcmp(imagePopoutAxes(1).DataAspectRatioMode, 'manual'), ... 'Image axes popout should preserve locked data aspect ratio.'); - assert(isequal(imagePopoutAxes(1).DataAspectRatio, imgAx.DataAspectRatio), ... - 'Image axes popout should preserve the source image pixel aspect ratio.'); -end - -function checkCreateAppShellHelper(h) - opts = struct(); - opts.rightTitle = 'Preview'; - opts.rightGridSize = [2 1]; - opts.rightRowHeight = {'1x', 'fit'}; - opts.rightRowSpacing = 7; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'labkit_create_app_shell_probe', ... - 'position', [40 30 1200 760], ... - 'leftWidth', 330, ... - 'options', opts)); - cleaner = onCleanup(@() delete(ui.fig)); - - assert(isequal(ui.main.ColumnWidth, {330, 6, '1x'}), ... - 'App shell helper should create the standard resizable left/separator/right layout.'); - h.assertTabTitles(ui.fig, {'Files + Analysis', 'Summary + Results', 'Log'}); - h.assertScrollablePanel(ui.filesAnalysisScrollPanel, 'Files + Analysis tab'); - h.assertScrollableGrid(ui.filesAnalysisGrid, 'Files + Analysis grid'); - assert(numel(ui.filesAnalysisResizeHandles) == 2, ... - 'Standard Files + Analysis tab should expose two row-resize handles.'); - assert(numel(ui.summaryResultsResizeHandles) == 1, ... - 'Standard Summary + Results tab should expose one row-resize handle.'); - assert(isequal(ui.filesAnalysisGrid.UserData.LabKitLogicalRowMap, [1 3 5]), ... - 'App shell should keep standard app rows mapped behind the shell.'); - resizeHandle = ui.filesAnalysisResizeHandles(1); - h.assertCallbackPresent(resizeHandle, 'ButtonDownFcn', 'row-resize handle'); - h.invokeCallback(resizeHandle, 'ButtonDownFcn'); - assert(~isempty(ui.fig.WindowButtonMotionFcn) && ~isempty(ui.fig.WindowButtonUpFcn), ... - 'Shell row-resize drag should install temporary figure motion callbacks.'); - h.invokeCallback(ui.fig, 'WindowButtonUpFcn'); - assert(isempty(ui.fig.WindowButtonMotionFcn) && isempty(ui.fig.WindowButtonUpFcn), ... - 'Shell row-resize drag should clear temporary figure callbacks after release.'); - assert(strcmp(ui.rightPanel.Title, 'Preview'), ... - 'App shell helper should preserve the requested right panel title.'); - assert(isequal(ui.rightGrid.RowHeight, {'1x', 'fit'}), ... - 'App shell helper should preserve custom right-grid rows.'); - - customOpts = struct(); - customOpts.rightTitle = 'Custom'; - customOpts.tabs = labkit.ui.app.tab( ... - 'probe', 'Probe Controls', [2 1], {'fit', '1x'}); - custom = labkit.ui.app.createShell(struct( ... - 'title', 'labkit_custom_tab_app_shell_probe', ... - 'position', [40 30 1200 760], ... - 'leftWidth', 330, ... - 'options', customOpts)); - cleaner3 = onCleanup(@() delete(custom.fig)); - h.assertTabTitles(custom.fig, {'Probe Controls'}); - h.assertScrollablePanel(custom.probeScrollPanel, 'Probe Controls tab'); - h.assertScrollableGrid(custom.probeGrid, 'Probe Controls grid'); - assert(isequal(custom.probeGrid.RowHeight, {'fit', 6, '1x'}), ... - 'App shell helper should preserve custom tab specs.'); - assert(numel(custom.probeResizeHandles) == 1, ... - 'Custom multi-row tabs should create default row-resize handles.'); - - fixedOpts = struct(); - fixedOpts.rightTitle = 'Fixed'; - fixedOpts.tabs = labkit.ui.app.tab( ... - 'fixed', 'Fixed Controls', [2 1], {'fit', '1x'}, ... - struct('resize', 'none')); - fixed = labkit.ui.app.createShell(struct( ... - 'title', 'labkit_fixed_tab_app_shell_probe', ... - 'position', [40 30 1200 760], ... - 'leftWidth', 330, ... - 'options', fixedOpts)); - cleaner4 = onCleanup(@() delete(fixed.fig)); - assert(h.sameStringCell(fixed.fixedGrid.RowHeight, {'fit', '1x'}), ... - 'Explicit fixed custom tabs should preserve row specs.'); - assert(isempty(fixed.fixedResizeHandles), ... - 'Custom tabs with resize none should not create resize handles.'); + function noop(varargin) + end end diff --git a/tests/gui/structural/labkit/ui/GuiLayoutUiBasicControlsTest.m b/tests/gui/structural/labkit/ui/GuiLayoutUiBasicControlsTest.m index bc9ccff..0e03935 100644 --- a/tests/gui/structural/labkit/ui/GuiLayoutUiBasicControlsTest.m +++ b/tests/gui/structural/labkit/ui/GuiLayoutUiBasicControlsTest.m @@ -1,5 +1,5 @@ classdef GuiLayoutUiBasicControlsTest < matlab.uitest.TestCase - %GUILAYOUTUIBASICCONTROLSTEST Verify LabKit behavior through official MATLAB tests. + %GUILAYOUTUIBASICCONTROLSTEST Verify UI 2.0 view helper contracts. methods (Test, TestTags = {'GUI', 'Structural'}) function test_gui_layout_ui_basic_controls(testCase) @@ -10,261 +10,71 @@ function test_gui_layout_ui_basic_controls(testCase) end function verify_gui_layout_ui_basic_controls() -%TEST_GUI_LAYOUT_UI_BASIC_CONTROLS Verify basic reusable UI controls/panels. +%TEST_GUI_LAYOUT_UI_BASIC_CONTROLS Verify final reusable UI view helpers. h = guiTestHelpers(); h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - checkListboxItemsRefreshHelper(h); - checkListboxSelectionHelper(h); - checkLabeledSpinnerHelper(); - checkLogPanelHelper(h); - checkReadOnlyTextHelpers(h); - checkReadOnlyInfoRowHelper(); - checkResultTablePanelHelper(h); - checkPanelGridHelper(h); - checkFileSelectionPanelHelper(h); -end - -function checkListboxItemsRefreshHelper(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_file_listbox_refresh_probe'); - cleaner = onCleanup(@() delete(fig)); - lb = uilistbox(fig, 'Items', {}, 'Multiselect', 'on'); - - labkit.ui.view.update(lb, 'listItems', {'a.DTA', 'b.DTA'}); - assert(h.sameStringCell(lb.Items, {'a.DTA', 'b.DTA'}), ... - 'File listbox helper should populate item display names.'); - assert(h.sameStringCell(lb.Value, {'a.DTA', 'b.DTA'}), ... - 'File listbox helper should select all items when there is no prior selection.'); - - lb.Value = {'b.DTA'}; - labkit.ui.view.update(lb, 'listItems', {'b.DTA', 'c.DTA'}); - assert(h.sameStringCell(lb.Items, {'b.DTA', 'c.DTA'}), ... - 'File listbox helper should update item display names.'); - assert(h.sameStringCell(lb.Value, {'b.DTA'}), ... - 'File listbox helper should preserve valid prior selections.'); - - labkit.ui.view.update(lb, 'listItems', {}); - assert(isempty(lb.Items) && isempty(lb.Value), ... - 'File listbox helper should clear listbox items and values for empty sessions.'); -end - -function checkListboxSelectionHelper(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_file_listbox_selection_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 1]); - - singleList = uilistbox(grid, 'Items', {}, 'Multiselect', 'off'); - [value, idx] = labkit.ui.view.update(singleList, 'listSelection', {'a.DTA', 'b.DTA'}, []); - assert(strcmp(value, 'a.DTA') && idx == 1, ... - 'Listbox selection helper should select the first single-select item by default.'); - - [value, idx] = labkit.ui.view.update(singleList, 'listSelection', {'b.DTA', 'c.DTA'}, 2); - assert(strcmp(value, 'c.DTA') && idx == 2, ... - 'Listbox selection helper should accept a preferred single-select index.'); - - multiList = uilistbox(grid, 'Items', {}, 'Multiselect', 'on'); - [value, idx] = labkit.ui.view.update( ... - multiList, 'listSelection', {'x.DTA', 'y.DTA'}, {}, ... - struct('defaultSelection', 'all')); - assert(h.sameStringCell(value, {'x.DTA', 'y.DTA'}) && isequal(idx, [1 2]), ... - 'Listbox selection helper should support selecting all multi-select items by default.'); - - [value, idx] = labkit.ui.view.update( ... - multiList, 'listSelection', {'y.DTA', 'z.DTA'}, {'y.DTA', 'missing.DTA'}, ... - struct('defaultSelection', 'all')); - assert(h.sameStringCell(value, {'y.DTA'}) && isequal(idx, 1), ... - 'Listbox selection helper should preserve only valid multi-select choices.'); -end - -function checkLogPanelHelper(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_log_panel_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 1]); - - ui = labkit.ui.view.panel(grid, 'log', 2, {'Started.'}); - assert(strcmp(ui.panel.Title, 'Log'), 'Log panel helper should preserve the panel title.'); - assert(ui.panel.Layout.Row == 2, 'Log panel helper should place the panel in the requested row.'); - assert(isequal(ui.grid.Padding, [8 8 8 8]), 'Log panel helper should preserve grid padding.'); - assert(strcmp(ui.textArea.Editable, 'off'), 'Log panel helper should create a read-only text area.'); - assert(h.sameStringCell(ui.textArea.Value, {'Started.'}), ... - 'Log panel helper should preserve supplied initial log text.'); -end - -function checkLabeledSpinnerHelper() - fig = uifigure('Visible', 'off', 'Name', 'labkit_labeled_spinner_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [1 2]); - - [lbl, spinner] = labkit.ui.view.form(grid, struct( ... - 'kind', 'spinner', ... - 'label', 'Probe value:', ... - 'value', 2, ... - 'limits', [0 10], ... - 'step', 0.5)); - assert(strcmp(lbl.Text, 'Probe value:'), ... - 'Labeled spinner helper should preserve label text.'); - assert(strcmp(lbl.HorizontalAlignment, 'right'), ... - 'Labeled spinner helper should right-align labels.'); - assert(spinner.Value == 2 && isequal(spinner.Limits, [0 10]) && spinner.Step == 0.5, ... - 'Labeled spinner helper should pass spinner options through.'); -end - -function checkReadOnlyTextHelpers(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_read_only_text_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 1]); - - field = labkit.ui.view.form(grid, struct( ... - 'kind', 'readonly', ... - 'value', 'Status')); - field.Layout.Row = 1; - assert(strcmp(field.Editable, 'off') && strcmp(field.Value, 'Status'), ... - 'Read-only text field helper should create a non-editable text field.'); - - panelUi = labkit.ui.view.panel(grid, 'text', 'Notes', 2, {'one', 'two'}); - assert(strcmp(panelUi.panel.Title, 'Notes'), ... - 'Read-only text panel helper should preserve the panel title.'); - assert(strcmp(panelUi.textArea.Editable, 'off') && ... - h.sameStringCell(panelUi.textArea.Value, {'one', 'two'}), ... - 'Read-only text panel helper should preserve read-only text lines.'); -end - -function checkReadOnlyInfoRowHelper() - fig = uifigure('Visible', 'off', 'Name', 'labkit_read_only_info_row_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 2]); - - [field, lbl] = labkit.ui.view.form(grid, struct( ... - 'kind', 'info', ... - 'row', 2, ... - 'label', 'Probe:')); - assert(strcmp(lbl.Text, 'Probe:'), 'Read-only info row should preserve label text.'); - assert(strcmp(lbl.HorizontalAlignment, 'right'), ... - 'Read-only info row should preserve right-aligned labels.'); - assert(lbl.Layout.Row == 2 && lbl.Layout.Column == 1, ... - 'Read-only info row should place the label in the requested row and first column.'); - assert(field.Layout.Row == 2 && field.Layout.Column == 2, ... - 'Read-only info row should place the field in the requested row and second column.'); - assert(strcmp(field.Editable, 'off'), ... - 'Read-only info row should create a read-only field.'); - assert(strcmp(field.Value, '-'), ... - 'Read-only info row should preserve the default empty summary value.'); -end - -function checkResultTablePanelHelper(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_result_table_panel_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 1]); - - ui = labkit.ui.view.panel(grid, 'table', 'Batch Results', 2, ... - {'File', 'Value'}, cell(0, 2)); - assert(strcmp(ui.panel.Title, 'Batch Results'), ... - 'Result table panel helper should preserve the panel title.'); - assert(ui.panel.Layout.Row == 2, ... - 'Result table panel helper should place the panel in the requested row.'); - assert(isequal(ui.grid.Padding, [8 8 8 8]), ... - 'Result table panel helper should preserve grid padding.'); - assert(h.sameStringCell(ui.table.ColumnName, {'File', 'Value'}), ... - 'Result table panel helper should preserve supplied column names.'); - assert(isequal(size(ui.table.Data), [0 2]), ... - 'Result table panel helper should preserve supplied empty table width.'); -end - -function checkPanelGridHelper(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_panel_grid_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 1]); - - ui = labkit.ui.view.section(grid, 'Probe Panel', 2, [3 2]); - assert(strcmp(ui.panel.Title, 'Probe Panel'), ... - 'Panel-grid helper should preserve the requested panel title.'); - assert(ui.panel.Layout.Row == 2, ... - 'Panel-grid helper should place the panel in the requested row.'); - assert(h.sameStringCell(ui.grid.RowHeight, {'fit', 'fit', 'fit'}), ... - 'Panel-grid helper should default to fit-height rows.'); - assert(h.sameStringCell(ui.grid.ColumnWidth, {'fit', '1x'}), ... - 'Panel-grid helper should default two-column controls to label/value widths.'); - assert(isequal(ui.grid.Padding, [8 8 8 8]), ... - 'Panel-grid helper should preserve standard padding.'); - - opts = struct('columnWidth', {{'1x', '1x'}}, 'padding', [0 0 0 0]); - ui2 = labkit.ui.view.section(grid, 'Actions', 1, [2 2], opts); - assert(h.sameStringCell(ui2.grid.ColumnWidth, {'1x', '1x'}), ... - 'Panel-grid helper should support explicit action-column widths.'); - assert(isequal(ui2.grid.Padding, [0 0 0 0]), ... - 'Panel-grid helper should support explicit padding.'); - - growGrid = uigridlayout(fig, [1 1]); - growGrid.RowHeight = {50}; - labkit.ui.view.section(growGrid, 'Tall Controls', 1, [5 2]); - assert(growGrid.RowHeight{1} > 50, ... - 'Panel-grid helper should grow undersized fixed parent rows to avoid clipped controls.'); -end - -function checkFileSelectionPanelHelper(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_file_selection_panel_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [3 1]); - - callbacks = struct(); - callbacks.onOpenFiles = @(~,~) []; - callbacks.onOpenFolder = @(~,~) []; - callbacks.onClearAll = @(~,~) []; - callbacks.onExport = @(~,~) []; - callbacks.onSelectFile = @(~,~) []; - - labels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'clearAll', 'Clear all', ... - 'export', 'Export results CSV', ... - 'loadedText', 'No files loaded'); - ui = labkit.ui.view.panel(grid, 'files', labels, callbacks); - assert(strcmp(ui.panel.Title, 'Files'), 'File-selection panel should preserve the panel title.'); - assert(ui.panel.Layout.Row == 1, 'File-selection panel should place the panel in row 1.'); - assert(h.sameStringCell(ui.grid.RowHeight, {'fit', '1x', 'fit'}), ... - 'File-selection panel should preserve row heights.'); - assert(h.sameStringCell(ui.grid.ColumnWidth, {'1x'}), ... - 'File-selection panel should preserve column widths.'); - assert(isequal(ui.grid.Padding, [8 8 8 8]), ... - 'File-selection panel should preserve padding.'); - assert(ui.grid.RowSpacing == 8 && ui.grid.ColumnSpacing == 0, ... - 'File-selection panel should preserve row and column spacing.'); - assert(isequal(ui.buttonGrid.ColumnWidth, {'1x', '1x'}), ... - 'File-selection panel should preserve button-grid columns.'); - assert(strcmp(ui.openButton.Text, 'Open DTA file(s)'), ... - 'File-selection panel should preserve the open-file button text.'); - assert(strcmp(ui.openFolderButton.Text, 'Open folder recursively'), ... - 'File-selection panel should preserve the open-folder button text.'); - assert(strcmp(ui.clearButton.Text, 'Clear all'), ... - 'File-selection panel should preserve the clear button text.'); - assert(strcmp(ui.exportButton.Text, 'Export results CSV'), ... - 'File-selection panel should preserve the export button text.'); - assert(strcmp(ui.listbox.Multiselect, 'off'), ... - 'File-selection panel should default to a single-select listbox.'); - assert(strcmp(ui.loadedText.Editable, 'off'), ... - 'File-selection panel should create a read-only loaded-count field.'); - assert(strcmp(ui.loadedText.Value, 'No files loaded'), ... - 'File-selection panel should preserve the default loaded-count text.'); - h.assertCallbackPresent(ui.openButton, 'ButtonPushedFcn', 'Open DTA file(s)'); - h.assertCallbackPresent(ui.openFolderButton, 'ButtonPushedFcn', 'Open folder recursively'); - h.assertCallbackPresent(ui.clearButton, 'ButtonPushedFcn', 'Clear all'); - h.assertCallbackPresent(ui.exportButton, 'ButtonPushedFcn', 'Export results CSV'); - h.assertCallbackPresent(ui.listbox, 'ValueChangedFcn', 'file listbox'); - - multiCallbacks = callbacks; - multiCallbacks.onRemoveSelected = @(~,~) []; - multiLabels = labels; - multiLabels.removeSelected = 'Remove selected'; - multiUi = labkit.ui.view.panel(grid, 'files', multiLabels, multiCallbacks, ... - struct('showRemoveSelected', true, 'multiselect', 'on', 'row', 2)); - assert(strcmp(multiUi.listbox.Multiselect, 'on'), ... - 'File-selection panel should support multi-select listboxes.'); - assert(strcmp(multiUi.removeButton.Text, 'Remove selected'), ... - 'File-selection panel should create remove-selected controls when requested.'); - assert(multiUi.exportButton.Layout.Row == 3, ... - 'File-selection panel should place export below remove/clear when remove is enabled.'); + spec = labkit.ui.spec.app('basicControlsProbe', 'Basic Controls Probe', ... + 'position', [120 100 980 680], ... + 'controlTabs', { ... + labkit.ui.spec.tab('setup', 'Setup', { ... + labkit.ui.spec.section('filesSection', 'Files', { ... + labkit.ui.spec.pathPanel('files', 'Files', ... + 'mode', 'multiFile', ... + 'selectionMode', 'multiple', ... + 'status', 'No files loaded'), ... + labkit.ui.spec.field('gain', 'Gain', ... + 'kind', 'spinner', ... + 'value', 1, ... + 'limits', [0 10]), ... + labkit.ui.spec.action('run', 'Run', @noop)})}), ... + labkit.ui.spec.tab('results', 'Results', { ... + labkit.ui.spec.section('resultsSection', 'Results', { ... + labkit.ui.spec.resultTable('table', 'Table', ... + 'columns', {'Name', 'Value'}), ... + labkit.ui.spec.statusPanel('details', 'Details', ... + 'value', {'Ready.'})})}), ... + labkit.ui.spec.tab('log', 'Log', { ... + labkit.ui.spec.section('logSection', 'Log', { ... + labkit.ui.spec.logPanel('logPanel', 'Log', ... + 'value', {'Started.'})})})}, ... + 'workspace', labkit.ui.spec.workspace('workspace', 'Preview', { ... + labkit.ui.spec.previewArea('preview', 'Preview', ... + 'layout', 'single', ... + 'axisIds', {'main'}, ... + 'axisTitles', {'Preview'})})); + ui = labkit.ui.app.create(spec); + + labkit.ui.view.setListItems(ui, 'files', {'a.dat', 'b.dat'}); + assert(h.sameStringCell(ui.controls.files.listbox.Items, {'a.dat', 'b.dat'}), ... + 'setListItems should populate semantic list-bearing controls.'); + [selection, idx] = labkit.ui.view.setListSelection( ... + ui, 'files', {'a.dat', 'b.dat'}, {'b.dat'}, struct()); + assert(h.sameStringCell(selection, {'b.dat'}) && isequal(idx, 2), ... + 'setListSelection should apply valid semantic multi-selection.'); + + labkit.ui.view.setValue(ui, 'gain', 4); + assert(labkit.ui.view.getValue(ui, 'gain') == 4, ... + 'setValue/getValue should round-trip field values by id.'); + labkit.ui.view.setEnabled(ui, 'run', false); + assert(strcmp(ui.controls.run.button.Enable, 'off'), ... + 'setEnabled should target action buttons by id.'); + + labkit.ui.view.appendLog(ui, 'logPanel', 'Completed.'); + assert(any(contains(string(ui.controls.logPanel.textArea.Value), 'Completed.')), ... + 'appendLog should append to a semantic log panel.'); + labkit.ui.view.drawImage(ui, 'preview', zeros(8, 9, 3, 'uint8'), ... + 'axis', 'main', 'title', 'Preview'); + ax = ui.controls.preview.primaryAxes; + assert(~isempty(ax.Children), 'drawImage should draw into a semantic preview axes.'); + labkit.ui.view.clearAxes(ui, 'preview', 'main'); + assert(isempty(ax.Children), 'clearAxes should remove preview axes children.'); + labkit.ui.view.resetAxes(ui, 'preview', 'Preview Reset', true, 'main'); + assert(strcmp(char(ax.Title.String), 'Preview Reset'), ... + 'resetAxes should retitle a semantic preview axes.'); + + function noop(varargin) + end end diff --git a/tests/gui/structural/labkit/ui/GuiLayoutUiDeclarativeAppTest.m b/tests/gui/structural/labkit/ui/GuiLayoutUiDeclarativeAppTest.m new file mode 100644 index 0000000..da00d47 --- /dev/null +++ b/tests/gui/structural/labkit/ui/GuiLayoutUiDeclarativeAppTest.m @@ -0,0 +1,153 @@ +classdef GuiLayoutUiDeclarativeAppTest < matlab.uitest.TestCase + %GUILAYOUTUIDECLARATIVEAPPTEST Verify UI 2.0 app builder contracts. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_declarative_app(testCase) + setupLabKitTestPath(); + verify_gui_layout_ui_declarative_app(); + end + end +end + +function verify_gui_layout_ui_declarative_app() +%TEST_GUI_LAYOUT_UI_DECLARATIVE_APP Verify UI 2.0 builder and registry helpers. + + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); + + events = {}; + spec = labkit.ui.spec.app('probeApp', 'UI 2 Probe', ... + 'position', [120 100 1100 760], ... + 'leftWidth', 390, ... + 'controlTabs', { ... + labkit.ui.spec.tab('setup', 'Setup', { ... + labkit.ui.spec.section('inputs', 'Inputs', { ... + labkit.ui.spec.pathPanel('sourceImages', 'Source images', ... + 'mode', 'multiFile', ... + 'selectionMode', 'single', ... + 'status', 'No images loaded', ... + 'dialogProvider', @dialogProvider, ... + 'onChoose', @captureEvent, ... + 'onSelectionChange', @captureEvent), ... + labkit.ui.spec.field('gain', 'Gain', ... + 'kind', 'spinner', ... + 'limits', [0 10], ... + 'value', 2, ... + 'onChange', @captureEvent), ... + labkit.ui.spec.rangeField('displayLimits', ... + 'Display limits', ... + 'limits', [0 1], ... + 'value', [0.1 0.9], ... + 'onChange', @captureEvent), ... + labkit.ui.spec.actionGroup('runActions', { ... + labkit.ui.spec.action('run', 'Run', @captureEvent, ... + 'priority', 'primary'), ... + labkit.ui.spec.action('reset', 'Reset', @captureEvent)})})}), ... + labkit.ui.spec.tab('review', 'Review', { ... + labkit.ui.spec.section('resultsSection', 'Results', { ... + labkit.ui.spec.resultTable('results', 'Results', ... + 'columns', {'Name', 'Status'}), ... + labkit.ui.spec.statusPanel('status', 'Status', ... + 'value', {'Idle'})})}), ... + labkit.ui.spec.tab('log', 'Log', { ... + labkit.ui.spec.section('logSection', 'Log', { ... + labkit.ui.spec.logPanel('logPanel', 'Log')})})}, ... + 'workspace', labkit.ui.spec.workspace('workspace', 'Preview', { ... + labkit.ui.spec.previewArea('preview', 'Preview', ... + 'layout', 'stack', ... + 'axisIds', {'raw', 'filtered', 'difference'}, ... + 'axisTitles', {'Raw', 'Filtered', 'Difference'}, ... + 'xLabels', {'Frame', 'Frame', 'Frame'}, ... + 'yLabels', {'Intensity', 'Intensity', 'Delta'}, ... + 'viewModes', {'Raw', 'Filtered', 'Difference'}, ... + 'onModeChange', @captureEvent)})); + + ui = labkit.ui.app.create(spec); + dialogPaths = dialogProvider(struct()); + drawnow; + h.assertFigureMinimumSize(ui.figure, 1100, 760); + h.assertTabTitles(ui.figure, {'Setup', 'Review', 'Log', 'Preview', ... + 'Inputs', 'Results'}); + h.assertButtonContract(ui.figure, {'Choose files', 'Clear', 'Run', 'Reset'}); + h.assertAxesContract(ui.figure, { ... + h.axesSpec('Raw', 'Frame', 'Intensity'), ... + h.axesSpec('Filtered', 'Frame', 'Intensity'), ... + h.axesSpec('Difference', 'Frame', 'Delta')}); + assert(isfield(ui.controls, 'sourceImages') && isfield(ui.controls, 'preview'), ... + 'UI registry should expose semantic control ids.'); + + ui.controls.sourceImages.chooseButton.ButtonPushedFcn( ... + ui.controls.sourceImages.chooseButton, []); + assert(isequal(ui.controls.sourceImages.listbox.Items, dialogPaths), ... + 'pathPanel choose should populate selected paths.'); + assert(strcmp(ui.controls.sourceImages.listbox.Multiselect, 'off') && ... + strcmp(ui.controls.sourceImages.listbox.Value, dialogPaths{1}), ... + 'pathPanel selectionMode single should keep a single current selection.'); + assert(strcmp(ui.controls.sourceImages.status.Value, '2 selected'), ... + 'pathPanel choose should update status text.'); + assert(~isempty(events) && strcmp(events{end}.id, 'sourceImages') && ... + strcmp(events{end}.action, 'choose') && ... + numel(events{end}.paths) == 2 && numel(events{end}.selection) == 1, ... + 'pathPanel choose should report chosen paths and current selection.'); + + ui.controls.sourceImages.listbox.Value = dialogPaths{2}; + ui.controls.sourceImages.listbox.ValueChangedFcn(ui.controls.sourceImages.listbox, []); + assert(strcmp(events{end}.action, 'select') && ... + isequal(events{end}.value, {dialogPaths{2}}), ... + 'pathPanel selection changes should emit semantic selection events.'); + + ui.controls.sourceImages.clearButton.ButtonPushedFcn( ... + ui.controls.sourceImages.clearButton, []); + assert(strcmp(ui.controls.sourceImages.status.Value, 'No images loaded'), ... + 'pathPanel clear should restore the configured empty status.'); + assert(strcmp(ui.controls.sourceImages.listbox.Items{1}, 'No selection'), ... + 'pathPanel clear should restore the placeholder item.'); + + labkit.ui.view.setValue(ui, 'gain', 4); + assert(labkit.ui.view.getValue(ui, 'gain') == 4, ... + 'setValue/getValue should target controls by semantic id.'); + labkit.ui.view.setValue(ui, 'displayLimits', [0.2 0.8]); + assert(isequal(labkit.ui.view.getValue(ui, 'displayLimits'), [0.2 0.8]), ... + 'rangeField values should round-trip through named helpers.'); + labkit.ui.view.setEnabled(ui, 'run', false); + assert(strcmp(ui.controls.run.button.Enable, 'off'), ... + 'setEnabled should update action buttons.'); + labkit.ui.view.setListItems(ui, 'sourceImages', {'a.png', 'b.png'}); + [selection, idx] = labkit.ui.view.setListSelection( ... + ui, 'sourceImages', {'a.png', 'b.png'}, {'b.png'}, struct()); + assert(strcmp(selection, 'b.png') && idx == 2, ... + 'List helper should apply semantic list selection.'); + labkit.ui.view.appendLog(ui, 'logPanel', 'Completed.'); + assert(any(contains(string(ui.controls.logPanel.textArea.Value), 'Completed.')), ... + 'appendLog should append to the requested log panel.'); + labkit.ui.view.drawImage(ui, 'preview', zeros(8, 8, 3, 'uint8'), ... + 'axis', 'filtered', 'title', 'Filtered'); + labkit.ui.view.resetAxes(ui, 'preview', 'Raw', true, 'raw'); + labkit.ui.view.clearAxes(ui, 'preview', 'difference'); + labkit.ui.view.setValue(ui, 'preview', 'Difference'); + ui.controls.preview.viewModeDropDown.ValueChangedFcn( ... + ui.controls.preview.viewModeDropDown, []); + assert(strcmp(events{end}.id, 'preview') && ... + strcmp(events{end}.mode, 'Difference'), ... + 'previewArea mode changes should emit semantic events.'); + + ui.controls.run.button.ButtonPushedFcn(ui.controls.run.button, []); + assert(~isempty(events) && strcmp(events{end}.id, 'run') && ... + strcmp(events{end}.kind, 'action') && ... + isequal(events{end}.ui.figure, ui.figure), ... + 'Action callbacks should receive semantic callback events.'); + + ui.controls.gain.handle.Value = 5; + ui.controls.gain.handle.ValueChangedFcn(ui.controls.gain.handle, []); + assert(strcmp(events{end}.id, 'gain') && events{end}.value == 5, ... + 'Field callbacks should report semantic id and current value.'); + + function captureEvent(~, event) + events{end+1, 1} = event; + end + + function paths = dialogProvider(~) + paths = {fullfile(tempdir, 'a.png'), fullfile(tempdir, 'b.png')}; + end +end diff --git a/tests/helpers/architectureTestHelpers.m b/tests/helpers/architectureTestHelpers.m index 7909dd0..db8d41b 100644 --- a/tests/helpers/architectureTestHelpers.m +++ b/tests/helpers/architectureTestHelpers.m @@ -51,8 +51,7 @@ [appName ' should not call internal analysis APIs directly.']); assert(~contains(appSource, 'labkit.util.'), ... [appName ' should not call utility APIs directly.']); - assert(contains(appOwnedSource, 'labkit.ui.app.createShell'), ... - [appName ' should build its GUI from the layered app shell facade.']); + assertUsesGuiFoundation(appOwnedSource, appName); assert(~contains(appOwnedSource, 'labkit.ui.create'), ... [appName ' should not call removed flat UI create* helpers.']); assert(~contains(appOwnedSource, 'labkit.ui.appendLog'), ... @@ -75,14 +74,13 @@ [appName ' should not use compatibility shell wrappers directly.']); assert(~contains(appOwnedSource, 'labkit.ui.createTabbedDualPlotShell'), ... [appName ' should not use compatibility shell wrappers directly.']); - forbiddenViewHelpers = {'appendLog', 'clearAxes', 'enablePopout', ... - 'fileSelectionPanel', 'logPanel', ... - 'refreshListboxItems', 'refreshListboxSelection', 'resetAxes', ... + forbiddenViewHelpers = {'enablePopout', 'fileSelectionPanel', 'logPanel', ... + 'refreshListboxItems', 'refreshListboxSelection', ... 'resultTable', 'showImage', 'textPanel'}; for iHelper = 1:numel(forbiddenViewHelpers) oldViewCall = ['labkit.ui.view.' forbiddenViewHelpers{iHelper}]; assert(~contains(appOwnedSource, oldViewCall), ... - [appName ' should use the unified view panel/draw/update facade instead of ' oldViewCall '.']); + [appName ' should not call removed legacy view helper ' oldViewCall '.']); end source = appOwnedSource; @@ -148,8 +146,7 @@ function assertDTAFacadeUsage(source, appName, expectedKind, expectsFolderDiscov function assertDICAppBoundary(source, appName) assert(~contains(source, 'labkit.dta.'), ... [appName ' should not use the electrochemistry DTA facade.']); - assert(contains(source, 'labkit.ui.app.createShell'), ... - [appName ' should build from the reusable GUI foundation.']); + assertUsesGuiFoundation(source, appName); assert(~contains(source, '+labkit/+dic'), ... [appName ' should keep DIC workflow code app-local.']); assertAppUsesManagedImageInteractions(source, appName); @@ -158,8 +155,7 @@ function assertDICAppBoundary(source, appName) function assertImageMeasurementAppBoundary(source, appName) assert(~contains(source, 'labkit.dta.'), ... [appName ' should not use the electrochemistry DTA facade.']); - assert(contains(source, 'labkit.ui.app.createShell'), ... - [appName ' should build from the reusable GUI foundation.']); + assertUsesGuiFoundation(source, appName); assert(~contains(source, '+labkit/+dic'), ... [appName ' should not depend on DIC implementation packages.']); assert(~contains(source, '+labkit/+image_measurement'), ... @@ -202,14 +198,18 @@ function assertImageMeasurementAppBoundary(source, appName) function assertWearableAppBoundary(source, appName) assert(~contains(source, 'labkit.dta.'), ... [appName ' should not use the electrochemistry DTA facade.']); - assert(contains(source, 'labkit.ui.app.createShell'), ... - [appName ' should build from the reusable GUI foundation.']); + assertUsesGuiFoundation(source, appName); assert(contains(source, 'labkit.biosignal.'), ... [appName ' should use the GUI-free biosignal facade for signal operations.']); assert(~contains(source, '+labkit/+ecg'), ... [appName ' should not depend on a separate ECG package.']); end +function assertUsesGuiFoundation(source, appName) + assert(contains(source, 'labkit.ui.app.create('), ... + [appName ' should build from the reusable GUI foundation.']); +end + function assertPackageMFiles(packageDir, expectedFiles, label) assert(exist(packageDir, 'dir') == 7, [label ' package directory should exist.']); diff --git a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m index 852a612..1b436e7 100644 --- a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m +++ b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m @@ -152,8 +152,14 @@ function verify_app_owned_workflow_boundaries() 'batch_crop_gui('); h.assertImageMeasurementAppBoundary(curvatureSource, 'labkit_CurvatureMeasurement_app'); h.assertImageMeasurementAppBoundary(focusStackSource, 'labkit_FocusStack_app'); + assertDeclarativeImageApp(focusStackSource, ... + 'labkit_FocusStack_app', 'focus_stack.ui.createRightAxesPair'); h.assertImageMeasurementAppBoundary(imageEnhanceSource, 'labkit_ImageEnhance_app'); + assertDeclarativeImageApp(imageEnhanceSource, ... + 'labkit_ImageEnhance_app', 'image_enhance.ui.createEditorUi'); h.assertImageMeasurementAppBoundary(imageMatchSource, 'labkit_ImageMatch_app'); + assertDeclarativeImageApp(imageMatchSource, ... + 'labkit_ImageMatch_app', 'image_match.ui.createEditorUi'); h.assertImageMeasurementAppBoundary(batchCropSource, 'labkit_BatchImageCrop_app'); assert(exist(fullfile(root, '+labkit', '+image_measurement'), 'dir') ~= 7, ... 'Image measurement workflow code should not be promoted to a reusable +labkit package yet.'); @@ -178,17 +184,39 @@ function verify_app_owned_workflow_boundaries() assert(exist(fullfile(root, '+labkit', '+ui', 'refreshSingleSelectFileListbox.m'), 'file') ~= 2, ... 'Item-schema-specific single-select file listbox refresh should stay in the owning app.'); assert(exist(fullfile(root, '+labkit', '+ui', 'resetTopBottomAxes.m'), 'file') ~= 2, ... - 'Title-specific top/bottom axes reset should stay in the owning app or call view.draw reset.'); + 'Title-specific top/bottom axes reset should stay in the owning app or call UI 2.0 view helpers.'); assert(exist(fullfile(root, '+labkit', '+ui', 'createTwoPaneShell.m'), 'file') ~= 2, ... - 'The old two-pane shell name should not be reintroduced; use app.createShell.'); + 'The old two-pane shell name should not be reintroduced; use UI 2.0 app specs.'); assert(exist(fullfile(root, '+labkit', '+ui', 'createStandardWorkbenchShell.m'), 'file') ~= 2, ... - 'Compatibility shell wrappers should not be reintroduced; use app.createShell.'); + 'Compatibility shell wrappers should not be reintroduced; use UI 2.0 app specs.'); assert(exist(fullfile(root, '+labkit', '+ui', 'createTabbedDualPlotShell.m'), 'file') ~= 2, ... - 'Compatibility shell wrappers should not be reintroduced; use app.createShell.'); + 'Compatibility shell wrappers should not be reintroduced; use UI 2.0 app specs.'); assert(exist(fullfile(root, '+labkit', '+ui', 'createSingleTabWorkbenchShell.m'), 'file') ~= 2, ... 'Single-tab app shells should not be reintroduced; use the standard three-tab workbench shell.'); assert(exist(fullfile(root, '+labkit', '+ui', 'createFilePanel.m'), 'file') ~= 2, ... - 'Separate file-button-only panels should not be reintroduced; use view.panel files.'); + 'Separate file-button-only panels should not be reintroduced; use UI 2.0 pathPanel specs.'); assert(exist(fullfile(root, '+labkit', '+ui', 'createSingleSelectFilePanel.m'), 'file') ~= 2, ... - 'Separate single-select file panels should not be reintroduced; use view.panel files.'); + 'Separate single-select file panels should not be reintroduced; use UI 2.0 pathPanel specs.'); +end + +function assertDeclarativeImageApp(source, appName, removedBuilderCall) + assert(contains(source, 'labkit.ui.app.create('), ... + [appName ' should launch through labkit.ui.app.create after UI 2.0 migration.']); + forbidden = { ... + 'labkit.ui.app.createShell' ... + 'labkit.ui.app.tab(' ... + removedBuilderCall ... + 'labkit.ui.view.section' ... + 'labkit.ui.view.form' ... + 'labkit.ui.view.panel' ... + 'labkit.ui.view.draw(' ... + 'labkit.ui.view.update(' ... + 'labkit.ui.view.place' ... + 'uigetfile(' ... + 'Layout.Row' ... + 'Layout.Column'}; + for k = 1:numel(forbidden) + assert(~contains(source, forbidden{k}), ... + [appName ' UI 2.0 path should not retain old UI debt: ' forbidden{k}]); + end end diff --git a/tests/integration/project/BuildTaskFrameworkGuardrailTest.m b/tests/integration/project/BuildTaskFrameworkGuardrailTest.m index fd86d09..5209ebb 100644 --- a/tests/integration/project/BuildTaskFrameworkGuardrailTest.m +++ b/tests/integration/project/BuildTaskFrameworkGuardrailTest.m @@ -109,6 +109,20 @@ function ciCoverageRunsOnlyOnManualOrScheduledWorkflows(testCase) 'Coverage job should upload coverage artifacts.'); end + function ciPushAndPullRequestsRunOnAllBranches(testCase) + root = setupLabKitTestPath(); + workflowPath = fullfile(root, ".github", "workflows", ... + "matlab-tests.yml"); + workflow = char(fileread(workflowPath)); + + testCase.verifyTrue(isempty(regexp(workflow, ... + '(?m)^ push:\s*\n\s+branches:', 'once')), ... + 'Push workflows should run on every branch, not only main.'); + testCase.verifyTrue(isempty(regexp(workflow, ... + '(?m)^ pull_request:\s*\n\s+branches:', 'once')), ... + 'Pull request workflows should run for every target branch.'); + end + function ciRepositoryStateChecksStayOutsideMatlab(testCase) root = setupLabKitTestPath(); workflowPath = fullfile(root, ".github", "workflows", ... diff --git a/tests/integration/project/PackageDependencyBoundariesTest.m b/tests/integration/project/PackageDependencyBoundariesTest.m index 2ddec91..1d3bf39 100644 --- a/tests/integration/project/PackageDependencyBoundariesTest.m +++ b/tests/integration/project/PackageDependencyBoundariesTest.m @@ -47,6 +47,12 @@ function verify_package_dependency_boundaries() h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+view', 'private'), ... uiForbidden, ... 'Reusable +labkit UI view private implementation'); + h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+spec'), ... + uiForbidden, ... + 'Reusable +labkit UI spec facade'); + h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+spec', 'private'), ... + uiForbidden, ... + 'Reusable +labkit UI spec private implementation'); h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+tool'), ... uiForbidden, ... 'Reusable +labkit UI tool facade'); diff --git a/tests/integration/project/PackagePublicSurfaceTest.m b/tests/integration/project/PackagePublicSurfaceTest.m index f2a06de..fc7c24e 100644 --- a/tests/integration/project/PackagePublicSurfaceTest.m +++ b/tests/integration/project/PackagePublicSurfaceTest.m @@ -31,28 +31,38 @@ function verify_package_public_surface() h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui'), {}, ... 'Layered +labkit UI root'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+app'), ... - {'createShell.m', 'dispatchRequest.m', 'runBusy.m', 'tab.m'}, ... + {'create.m', 'dispatchRequest.m', 'runBusy.m'}, ... 'UI app facade'); h.assertPackageMFiles(fullfile(root, '+labkit', '+ui', '+app', 'private'), ... {'addRowResizeHandle.m', 'attachColumnResize.m', ... - 'createTabbedWorkbenchShell.m'}, ... + 'buildControl.m', 'buildControlTabs.m', 'buildSection.m', ... + 'buildShellFromSpec.m', 'buildWorkspace.m', ... + 'createTabbedWorkbenchShell.m', 'enableAxesPopout.m', ... + 'popoutAxes.m', 'semanticEvent.m', 'validateAppSpec.m'}, ... 'UI app private implementation'); h.assertPackageMFiles(fullfile(root, '+labkit', '+ui', '+diag'), ... {'createContext.m'}, ... 'UI diagnostics facade'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+view'), ... - {'axes.m', 'draw.m', 'form.m', 'panel.m', 'place.m', ... - 'section.m', 'update.m'}, ... + {'appendLog.m', 'clearAxes.m', 'drawImage.m', 'getValue.m', ... + 'resetAxes.m', 'setEnabled.m', 'setListItems.m', ... + 'setListSelection.m', 'setValue.m'}, ... 'UI view facade'); h.assertPackageMFiles(fullfile(root, '+labkit', '+ui', '+view', 'private'), ... - {'appendLog.m', 'clearAxes.m', 'createLabeledDropdown.m', ... - 'createLabeledEditField.m', 'createLabeledSpinner.m', ... - 'createReadOnlyInfoRow.m', 'createReadOnlyTextField.m', ... - 'enablePopout.m', 'fileSelectionPanel.m', 'layoutRow.m', ... - 'logPanel.m', 'popoutAxes.m', ... - 'refreshListboxItems.m', 'refreshListboxSelection.m', ... - 'resetAxes.m', 'resultTable.m', 'showImage.m', 'textPanel.m'}, ... + {'appendLog.m', 'clearAxes.m', 'controlAxes.m', ... + 'controlHandles.m', 'controlValueHandle.m', 'enablePopout.m', ... + 'popoutAxes.m', 'resolveControl.m', 'refreshListboxItems.m', ... + 'refreshListboxSelection.m', 'resetAxes.m', 'showImage.m'}, ... 'UI view private implementation'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+spec'), ... + {'action.m', 'actionGroup.m', 'app.m', 'custom.m', 'field.m', ... + 'logPanel.m', 'pathPanel.m', 'previewArea.m', 'rangeField.m', ... + 'resultTable.m', 'section.m', 'statusPanel.m', 'tab.m', ... + 'workspace.m'}, ... + 'UI 2.0 spec facade'); + h.assertPackageMFiles(fullfile(root, '+labkit', '+ui', '+spec', 'private'), ... + {'makeSpec.m', 'optionStruct.m', 'specChildren.m'}, ... + 'UI 2.0 spec private implementation'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+tool'), ... {'anchorEditor.m', 'createRuntime.m', 'scaleBar.m', ... 'scaleBarCalibration.m'}, ... @@ -62,8 +72,8 @@ function verify_package_public_surface() 'createLabeledDropdown.m', 'createLabeledEditField.m', ... 'createLabeledSpinner.m', 'createReadOnlyInfoRow.m', ... 'createReadOnlyTextField.m', 'defaultScaleBarUnits.m', ... - 'drawScaleBarOverlay.m', 'normalizeScaleBarUnit.m', ... - 'scaleBarPanel.m'}, ... + 'drawScaleBarOverlay.m', 'enableAxesPopout.m', ... + 'normalizeScaleBarUnit.m', 'popoutAxes.m', 'scaleBarPanel.m'}, ... 'UI tool private implementation'); assertNoPublicPackage(fullfile(root, '+labkit', '+ui', '+control'), ... 'UI control helpers should stay private instead of becoming a public helper-dump package.'); diff --git a/tests/integration/project/ProjectDebtGuardrailTest.m b/tests/integration/project/ProjectDebtGuardrailTest.m index 2385adf..cec1d7d 100644 --- a/tests/integration/project/ProjectDebtGuardrailTest.m +++ b/tests/integration/project/ProjectDebtGuardrailTest.m @@ -36,21 +36,13 @@ function oversizedAppEntrypointDebtIsRemoved(testCase) fprintf('Entrypoint size debt inventory: %d files over 500 lines.\n', numel(actual)); end - function oversizedRunnerDebtDoesNotGrow(testCase) + function oversizedRunnerDebtIsRemoved(testCase) root = setupLabKitTestPath(); - expectedFiles = expectedOversizedRunnerDebtFiles(); actualFiles = collectOversizedAppRunners(root, 500); - unexpectedFiles = setdiff(actualFiles, expectedFiles); - staleFiles = setdiff(expectedFiles, actualFiles); - testCase.verifyTrue(isempty(unexpectedFiles), ... - ['expected-debt: oversized app runners should not grow. ' ... + testCase.verifyTrue(isempty(actualFiles), ... + ['oversized app runners must not remain. ' ... 'Split deterministic behavior into app-owned +ops/+view/+export/+io/+state ' ... - 'before moving runner bodies. Files: ' ... - strjoin(cellstr(unexpectedFiles), ', ')]); - testCase.verifyTrue(isempty(staleFiles), ... - ['expected-debt: oversized app runner inventory includes ' ... - 'resolved files. Remove them from expectedOversizedRunnerDebtFiles: ' ... - strjoin(cellstr(staleFiles), ', ')]); + 'before moving runner bodies. Files: ' strjoin(cellstr(actualFiles), ', ')]); fprintf('Oversized runner debt inventory: %d files over 500 lines.\n', ... numel(actualFiles)); @@ -96,31 +88,17 @@ function oldRunnerDependenciesAreRemoved(testCase) fprintf('Old runner dependency inventory: %d files.\n', numel(dependencyFiles)); end - function appPrivateRunnerDebtDoesNotGrow(testCase) + function appPrivateRunnerDebtIsRemoved(testCase) root = setupLabKitTestPath(); - expectedDirs = strings(1, 0); actualDirs = collectAppPrivateDirs(root); - unexpectedDirs = setdiff(actualDirs, expectedDirs); - staleDirs = setdiff(expectedDirs, actualDirs); - testCase.verifyTrue(isempty(unexpectedDirs), ... - ['expected-debt: new app private helper directories are not allowed. Files: ' ... - strjoin(cellstr(unexpectedDirs), ', ')]); - testCase.verifyTrue(isempty(staleDirs), ... - ['expected-debt: app private helper directory inventory includes ' ... - 'resolved directories. Remove them from expectedDirs: ' ... - strjoin(cellstr(staleDirs), ', ')]); - - expectedFiles = expectedAppPrivateDebtFiles(); + testCase.verifyTrue(isempty(actualDirs), ... + ['app private helper directories are not allowed. Files: ' ... + strjoin(cellstr(actualDirs), ', ')]); + actualFiles = collectAppPrivateMFiles(root); - unexpectedFiles = setdiff(actualFiles, expectedFiles); - staleFiles = setdiff(expectedFiles, actualFiles); - testCase.verifyTrue(isempty(unexpectedFiles), ... - ['expected-debt: app private helper debt grew. Files: ' ... - strjoin(cellstr(unexpectedFiles), ', ')]); - testCase.verifyTrue(isempty(staleFiles), ... - ['expected-debt: app private helper inventory includes resolved files. ' ... - 'Remove them from expectedAppPrivateDebtFiles: ' ... - strjoin(cellstr(staleFiles), ', ')]); + testCase.verifyTrue(isempty(actualFiles), ... + ['app private helper debt must not remain. Files: ' ... + strjoin(cellstr(actualFiles), ', ')]); fprintf('App private helper debt inventory: %d files in %d directories.\n', ... numel(actualFiles), numel(actualDirs)); @@ -164,35 +142,13 @@ function appWorkflowDispatchDebtDoesNotGrow(testCase) numel(workflowFiles), numel(dispatchFiles)); end - function appUiRunnersDoNotShadowExtractedPackageHelpers(testCase) + function appUiRunnersAreNotUsedForAppLifecycle(testCase) root = setupLabKitTestPath(); - runners = collectRelativeFiles(root, ... + uiRunners = collectRelativeFiles(root, ... fullfile(root, 'apps', '**', '+ui', 'runApp.m')); - findings = strings(numel(runners), 1); - findingCount = 0; - - for k = 1:numel(runners) - runnerPath = fullfile(root, strrep(runners(k), '/', filesep)); - packageRoot = owningPackageRootForRunner(runnerPath); - if strlength(packageRoot) == 0 - continue; - end - - runnerFunctions = setdiff(functionNamesInFile(runnerPath), "runApp"); - packageFunctions = packageComponentFunctionNames(packageRoot); - overlap = intersect(runnerFunctions, packageFunctions); - if ~isempty(overlap) - findingCount = findingCount + 1; - findings(findingCount) = runners(k) + " -> " + ... - strjoin(overlap, ", "); - end - end - findings = findings(1:findingCount); - - testCase.verifyTrue(isempty(findings), ... - ['App UI runners should call extracted app-owned package helpers, ' ... - 'not keep same-named local copies. Findings: ' ... - strjoin(cellstr(findings), ', ')]); + testCase.verifyTrue(isempty(uiRunners), ... + ['App lifecycle runners belong at package root run.m, not ' ... + '+ui/runApp.m. Files: ' strjoin(cellstr(uiRunners), ', ')]); end function dicWearableMigrationsHaveDirectPackageTests(testCase) @@ -316,7 +272,7 @@ function dicWearableMigrationsHaveDirectPackageTests(testCase) function files = collectOversizedAppRunners(root, maxLines) entries = [ ... dir(fullfile(root, 'apps', '**', 'private', 'run*App.m')); ... - dir(fullfile(root, 'apps', '**', '+ui', 'runApp.m'))]; + dir(fullfile(root, 'apps', '**', '+*', 'run.m'))]; files = strings(numel(entries), 1); fileCount = 0; for k = 1:numel(entries) @@ -342,44 +298,6 @@ function dicWearableMigrationsHaveDirectPackageTests(testCase) files = unique(files); end -function packageRoot = owningPackageRootForRunner(runnerPath) - uiDir = fileparts(runnerPath); - packageRoot = string(fileparts(uiDir)); - [~, packageName] = fileparts(char(packageRoot)); - if ~startsWith(packageName, '+') - packageRoot = ""; - end -end - -function names = packageComponentFunctionNames(packageRoot) - components = ["+ops", "+view", "+export", "+io", "+state"]; - filesByComponent = cell(numel(components), 1); - for k = 1:numel(components) - componentRoot = fullfile(packageRoot, components(k)); - filesByComponent{k} = dir(fullfile(componentRoot, '*.m')); - end - - files = vertcat(filesByComponent{:}); - names = strings(numel(files), 1); - for k = 1:numel(files) - [~, name] = fileparts(files(k).name); - names(k) = string(name); - end - names = unique(names); -end - -function names = functionNamesInFile(filepath) - content = fileread(filepath); - withOutput = regexp(content, ... - '(?m)^\s*function\s+(?:\[[^\]]+\]|\w+)\s*=\s*(\w+)\s*\(', ... - 'tokens'); - withoutOutput = regexp(content, ... - '(?m)^\s*function\s+(\w+)\s*\(', ... - 'tokens'); - names = [tokenValues(withOutput); tokenValues(withoutOutput)]; - names = unique(names); -end - function files = collectRelativeFiles(root, pattern) entries = dir(pattern); files = strings(numel(entries), 1); @@ -470,14 +388,6 @@ function dicWearableMigrationsHaveDirectPackageTests(testCase) end end -function files = expectedAppPrivateDebtFiles() - files = strings(1, 0); -end - -function files = expectedOversizedRunnerDebtFiles() - files = strings(1, 0); -end - function actual = collectOversizedEntrypoints(root, maxLines) appFiles = dir(fullfile(root, 'apps', '**', 'labkit_*_app.m')); actual = strings(numel(appFiles), 1); @@ -512,10 +422,3 @@ function dicWearableMigrationsHaveDirectPackageTests(testCase) paths{k} = relativePath(root, filepaths{k}); end end - -function values = tokenValues(tokens) - values = strings(numel(tokens), 1); - for k = 1:numel(tokens) - values(k) = string(tokens{k}{1}); - end -end diff --git a/tests/integration/project/ProjectDocumentationGuardrailTest.m b/tests/integration/project/ProjectDocumentationGuardrailTest.m index 5e11bda..d90d143 100644 --- a/tests/integration/project/ProjectDocumentationGuardrailTest.m +++ b/tests/integration/project/ProjectDocumentationGuardrailTest.m @@ -70,21 +70,12 @@ function publicLibraryFunctionsDocumentAppFacingContracts(testCase) 'after the function declaration: ' strjoin(cellstr(missing), ', ')]); end - function privateHelperContractDebtDoesNotGrow(testCase) + function privateHelperContractDebtIsRemoved(testCase) root = setupLabKitTestPath(); - expectedFiles = expectedPrivateContractDebtFiles(); actual = collectPrivateContractDebt(root); - unexpectedFiles = setdiff(actual, expectedFiles); - staleFiles = setdiff(expectedFiles, actual); - testCase.verifyTrue(isempty(unexpectedFiles), ... - ['expected-debt: new private helpers without implementation contracts: ' ... - strjoin(cellstr(unexpectedFiles), ', ')]); - testCase.verifyTrue(isempty(staleFiles), ... - ['expected-debt: private helper contract inventory includes ' ... - 'resolved files. Remove them from expectedPrivateContractDebtFiles: ' ... - strjoin(cellstr(staleFiles), ', ')]); - testCase.verifyLessThanOrEqual(numel(actual), numel(expectedFiles), ... - 'Private helper implementation contract debt should only shrink.'); + testCase.verifyTrue(isempty(actual), ... + ['private helpers without implementation contracts must not remain: ' ... + strjoin(cellstr(actual), ', ')]); fprintf('Private helper contract debt inventory: %d files missing top-of-file contracts.\n', ... numel(actual)); @@ -198,10 +189,6 @@ function appOwnedPackageHelpersDocumentImplementationContracts(testCase) actual = unique(actual); end -function files = expectedPrivateContractDebtFiles() - files = strings(1, 0); -end - function files = collectAppOwnedPackageFiles(root) entries = dir(fullfile(root, 'apps', '**', '+*', '**', '*.m')); files = strings(1, 0); diff --git a/tests/integration/project/ProjectStructureGuardrailTest.m b/tests/integration/project/ProjectStructureGuardrailTest.m index 79047ee..c7a798a 100644 --- a/tests/integration/project/ProjectStructureGuardrailTest.m +++ b/tests/integration/project/ProjectStructureGuardrailTest.m @@ -22,15 +22,22 @@ function publicPackageSurfaceMatchesDocumentedFacades(testCase) h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui'), {}, ... 'Layered +labkit UI root'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+app'), ... - {'createShell.m', 'dispatchRequest.m', 'runBusy.m', 'tab.m'}, ... + {'create.m', 'dispatchRequest.m', 'runBusy.m'}, ... 'UI app facade'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+diag'), ... {'createContext.m'}, ... 'UI diagnostics facade'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+view'), ... - {'axes.m', 'draw.m', 'form.m', 'panel.m', 'place.m', ... - 'section.m', 'update.m'}, ... + {'appendLog.m', 'clearAxes.m', 'drawImage.m', ... + 'getValue.m', 'resetAxes.m', 'setEnabled.m', ... + 'setListItems.m', 'setListSelection.m', 'setValue.m'}, ... 'UI view facade'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+spec'), ... + {'action.m', 'actionGroup.m', 'app.m', 'custom.m', ... + 'field.m', 'logPanel.m', 'pathPanel.m', ... + 'previewArea.m', 'rangeField.m', 'resultTable.m', ... + 'section.m', 'statusPanel.m', 'tab.m', 'workspace.m'}, ... + 'UI 2.0 spec facade'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+tool'), ... {'anchorEditor.m', 'createRuntime.m', 'scaleBar.m', ... 'scaleBarCalibration.m'}, ... @@ -147,6 +154,16 @@ function imageMeasurementAppsUseOwnedPackageNamespaces(testCase) 'image_match', 'image_match', 'labkit_ImageMatch_app.m'); end + function ui2MigratedAppsUseCanonicalAppStructure(testCase) + root = setupLabKitTestPath(); + specs = migratedUi2AppSpecs(); + + for k = 1:size(specs, 1) + assertMigratedUi2AppStructure(testCase, root, ... + specs{k, 1}, specs{k, 2}, specs{k, 3}); + end + end + function electrochemAppsUseOwnedPackageNamespaces(testCase) root = setupLabKitTestPath(); @@ -306,6 +323,172 @@ function assertImageMeasurementPackageLayout(testCase, root, appFolder, packageN 'image_measurement', packageName); end +function specs = migratedUi2AppSpecs() + specs = { ... + fullfile('apps', 'image_measurement', 'batch_crop'), ... + 'batch_crop', 'labkit_BatchImageCrop_app.m'; ... + fullfile('apps', 'image_measurement', 'curvature'), ... + 'curvature', 'labkit_CurvatureMeasurement_app.m'; ... + fullfile('apps', 'image_measurement', 'focus_stack'), ... + 'focus_stack', 'labkit_FocusStack_app.m'; ... + fullfile('apps', 'image_measurement', 'image_enhance'), ... + 'image_enhance', 'labkit_ImageEnhance_app.m'; ... + fullfile('apps', 'image_measurement', 'image_match'), ... + 'image_match', 'labkit_ImageMatch_app.m'; ... + fullfile('apps', 'electrochem', 'cic'), ... + 'cic', 'labkit_CIC_app.m'; ... + fullfile('apps', 'electrochem', 'chrono_overlay'), ... + 'chrono_overlay', 'labkit_ChronoOverlay_app.m'; ... + fullfile('apps', 'electrochem', 'csc'), ... + 'csc', 'labkit_CSC_app.m'; ... + fullfile('apps', 'electrochem', 'eis'), ... + 'eis', 'labkit_EIS_app.m'; ... + fullfile('apps', 'electrochem', 'vt_resistance'), ... + 'vt_resistance', 'labkit_VTResistance_app.m'; ... + fullfile('apps', 'wearable', 'ecg_print'), ... + 'ecg_print', 'labkit_ECGPrint_app.m'; ... + fullfile('apps', 'dic', 'dic_preprocess'), ... + 'dic_preprocess', 'labkit_DICPreprocess_app.m'; ... + fullfile('apps', 'dic', 'dic_postprocess'), ... + 'dic_postprocess', 'labkit_DICPostprocess_app.m'}; +end + +function assertMigratedUi2AppStructure(testCase, root, appRelDir, packageName, entrypointName) + appDir = fullfile(root, appRelDir); + packageDir = fullfile(appDir, ['+' packageName]); + uiDir = fullfile(packageDir, '+ui'); + entrypointFile = fullfile(appDir, entrypointName); + runFile = fullfile(packageDir, 'run.m'); + buildSpecFile = fullfile(uiDir, 'buildSpec.m'); + appLabel = relativePath(root, appDir); + + testCase.verifyTrue(isfile(buildSpecFile), ... + ['UI 2.0 migrated apps must keep the ordinary data-only spec at ' ... + relativePath(root, buildSpecFile)]); + testCase.verifyTrue(isfile(entrypointFile), ... + ['Missing migrated app entrypoint: ' relativePath(root, entrypointFile)]); + testCase.verifyTrue(isfile(runFile), ... + ['Migrated app lifecycle runner must live at package root: ' ... + relativePath(root, runFile)]); + testCase.verifyFalse(isfile(fullfile(uiDir, 'runApp.m')), ... + [appLabel ' should not keep app lifecycle orchestration in +ui/runApp.m.']); + + orchestrationSource = migratedAppOrchestrationSource(entrypointFile, ... + runFile); + testCase.verifyTrue(contains(orchestrationSource, [packageName '.ui.buildSpec(']), ... + [appLabel ' should call its canonical +ui/buildSpec.m file.']); + testCase.verifyTrue(contains(orchestrationSource, 'labkit.ui.app.create('), ... + [appLabel ' should launch through labkit.ui.app.create.']); + + buildSpecSource = fileread(buildSpecFile); + testCase.verifyTrue(contains(buildSpecSource, 'labkit.ui.spec.app'), ... + [relativePath(root, buildSpecFile) ' should return a UI 2.0 app spec.']); + assertSourceDoesNotContain(testCase, buildSpecSource, ... + buildSpecForbiddenWords(), relativePath(root, buildSpecFile)); + + packageSource = readPackageSource(packageDir); + assertSourceDoesNotContain(testCase, packageSource, ... + migratedUiForbiddenWords(), appLabel); + testCase.verifyFalse(contains(packageSource, 'labkit.ui.spec.custom'), ... + [appLabel ' should keep ordinary migrated app UI custom count at 0.']); + + assertNoGenericHelperNames(testCase, root, packageDir); + assertRolePackageBoundaries(testCase, root, packageDir); +end + +function source = migratedAppOrchestrationSource(entrypointFile, runFile) + source = strjoin({fileread(entrypointFile), fileread(runFile)}, newline); +end + +function words = buildSpecForbiddenWords() + words = {'uifigure(', 'uigridlayout(', 'uibutton(', 'uilabel(', ... + 'uidropdown(', 'uispinner(', 'uieditfield(', 'uitable(', ... + 'uiaxes(', 'uitextarea(', 'labkit.ui.app.create', ... + 'Layout.Row', 'Layout.Column', 'uigetfile(', 'uigetdir(', ... + 'uiputfile(', 'uialert(', 'writetable(', 'imwrite(', 'S.'}; +end + +function words = migratedUiForbiddenWords() + words = {'labkit.ui.app.createShell', 'labkit.ui.app.tab(', ... + 'labkit.ui.view.section', 'labkit.ui.view.form', ... + 'labkit.ui.view.panel', 'labkit.ui.view.draw(', ... + 'labkit.ui.view.update(', 'labkit.ui.view.place', ... + 'uigridlayout(', 'Layout.Row', 'Layout.Column', ... + 'createRightAxesPair', 'createEditorUi', 'createUi('}; +end + +function assertNoGenericHelperNames(testCase, root, packageDir) + forbidden = {'helpers.m', 'utils.m', 'common.m', 'misc.m', ... + 'functions.m', 'callbacks.m', 'manager.m', 'processor.m', ... + 'newUI.m', 'layout.m', 'layout2.m', 'createUI.m', 'makeUI.m', ... + 'place.m', 'createRightAxesPair.m', 'createEditorUi.m', 'createUi.m'}; + files = dir(fullfile(packageDir, '**', '*.m')); + bad = strings(1, 0); + for k = 1:numel(files) + if any(strcmp(files(k).name, forbidden)) + bad(end+1) = string(relativePath(root, ... + fullfile(files(k).folder, files(k).name))); + end + end + + testCase.verifyTrue(isempty(bad), ... + ['Migrated app packages should name files by stable role/output, not ' ... + 'generic helper buckets: ' strjoin(cellstr(bad), ', ')]); +end + +function assertRolePackageBoundaries(testCase, root, packageDir) + assertComponentSourcesDoNotContain(testCase, root, fullfile(packageDir, '+ops'), ... + {'labkit.ui', 'uialert(', 'uigetfile(', 'uigetdir(', ... + 'uiputfile(', 'writetable(', 'imwrite('}); + assertComponentSourcesDoNotContain(testCase, root, fullfile(packageDir, '+view'), ... + {'labkit.ui.app.create', 'uigridlayout(', 'uiaxes(', 'uialert(', ... + 'uigetfile(', 'uigetdir(', 'uiputfile(', 'writetable(', 'imwrite('}); + assertComponentSourcesDoNotContain(testCase, root, fullfile(packageDir, '+io'), ... + {'labkit.ui', 'uialert(', 'uigridlayout(', 'writetable(', 'imwrite('}); + assertComponentSourcesDoNotContain(testCase, root, fullfile(packageDir, '+export'), ... + {'labkit.ui', 'uialert(', 'uigetfile(', 'uigetdir(', ... + 'uiputfile(', 'uigridlayout('}); + assertComponentSourcesDoNotContain(testCase, root, fullfile(packageDir, '+state'), ... + {'labkit.ui', 'uialert(', 'uigetfile(', 'uigetdir(', ... + 'uiputfile(', 'writetable(', 'imwrite(', 'uigridlayout('}); +end + +function assertComponentSourcesDoNotContain(testCase, root, folder, forbiddenWords) + if ~isfolder(folder) + return; + end + + files = dir(fullfile(folder, '*.m')); + for k = 1:numel(files) + filepath = fullfile(files(k).folder, files(k).name); + assertSourceDoesNotContain(testCase, fileread(filepath), ... + forbiddenWords, relativePath(root, filepath)); + end +end + +function assertSourceDoesNotContain(testCase, source, forbiddenWords, label) + matches = strings(1, 0); + for k = 1:numel(forbiddenWords) + word = forbiddenWords{k}; + if contains(source, word) + matches(end+1) = string(word); + end + end + + testCase.verifyTrue(isempty(matches), ... + [label ' contains code outside its migrated app structure boundary: ' ... + strjoin(cellstr(matches), ', ')]); +end + +function source = readPackageSource(packageDir) + files = dir(fullfile(packageDir, '**', '*.m')); + parts = cell(1, numel(files)); + for k = 1:numel(files) + parts{k} = fileread(fullfile(files(k).folder, files(k).name)); + end + source = strjoin(parts, newline); +end + function assertAppOwnedPackageCapability(testCase, root, appDir, packageDir, family, packageName) testCase.verifyTrue(isfolder(packageDir), ... ['Missing app-owned package namespace: ' relativePath(root, packageDir)]); @@ -324,12 +507,9 @@ function assertAppOwnedPackageCapability(testCase, root, appDir, packageDir, fam ['App-owned non-UI package functions should have direct unit tests: ' ... relativePath(root, packageDir)]); - uiRunApp = fullfile(packageDir, '+ui', 'runApp.m'); - if isfile(uiRunApp) - testCase.verifyTrue(numel(packageFiles) > 1, ... - ['App-owned package should not be only a +ui/runApp.m wrapper: ' ... - relativePath(root, appDir)]); - end + testCase.verifyFalse(isfile(fullfile(packageDir, '+ui', 'runApp.m')), ... + ['App-owned package should not keep app lifecycle orchestration in +ui/runApp.m: ' ... + relativePath(root, appDir)]); end function tf = hasNonUiPackageComponent(packageDir) diff --git a/tests/integration/project/TestCompatibilityDebtGuardrailTest.m b/tests/integration/project/TestCompatibilityDebtGuardrailTest.m index f36cde8..c4b92ec 100644 --- a/tests/integration/project/TestCompatibilityDebtGuardrailTest.m +++ b/tests/integration/project/TestCompatibilityDebtGuardrailTest.m @@ -47,7 +47,7 @@ function projectDebtGuardrailsUseCurrentGovernanceLabels(testCase) strjoin(cellstr(findings), ', ')]); end - function exactDebtInventoriesStayNamedAndNarrow(testCase) + function exactDebtInventoriesAreRemoved(testCase) root = setupLabKitTestPath(); files = collectMFiles(fullfile(root, 'tests', 'integration', 'project')); inventoryFunctions = strings(1, 0); @@ -62,15 +62,10 @@ function exactDebtInventoriesStayNamedAndNarrow(testCase) end end - expected = [ ... - "tests/integration/project/ProjectDebtGuardrailTest.m -> expectedAppPrivateDebtFiles", ... - "tests/integration/project/ProjectDebtGuardrailTest.m -> expectedOversizedRunnerDebtFiles", ... - "tests/integration/project/ProjectDocumentationGuardrailTest.m -> expectedPrivateContractDebtFiles"]; - unexpected = setdiff(inventoryFunctions, expected); - testCase.verifyTrue(isempty(unexpected), ... - ['New exact debt inventories need an explicit governance reason. ' ... - 'Prefer capability guardrails for package layout and test quality. Findings: ' ... - strjoin(cellstr(unexpected), ', ')]); + testCase.verifyTrue(isempty(inventoryFunctions), ... + ['Exact expected-debt inventories must not remain. Prefer capability ' ... + 'guardrails for package layout and test quality. Findings: ' ... + strjoin(cellstr(inventoryFunctions), ', ')]); end function trackedEditorNoiseFilesAreForbidden(testCase) diff --git a/tests/support/uiSpecCustomProbe.m b/tests/support/uiSpecCustomProbe.m new file mode 100644 index 0000000..5ccf238 --- /dev/null +++ b/tests/support/uiSpecCustomProbe.m @@ -0,0 +1,10 @@ +function handle = uiSpecCustomProbe(parent, id, context, props) +%UISPECCUSTOMPROBE Test-only custom UI 2.0 builder. +% +% Expected caller: UI 2.0 spec tests. Inputs mirror custom builder contract: +% parent container, semantic id, context struct, and props struct. Output is a +% simple label handle registered by labkit.ui.app.create. + + labelText = sprintf('%s:%s:%d', id, class(context), numel(fieldnames(props))); + handle = uilabel(parent, 'Text', labelText); +end diff --git a/tests/unit/labkit/ui/UiSpecTest.m b/tests/unit/labkit/ui/UiSpecTest.m new file mode 100644 index 0000000..363fed6 --- /dev/null +++ b/tests/unit/labkit/ui/UiSpecTest.m @@ -0,0 +1,155 @@ +classdef UiSpecTest < matlab.unittest.TestCase + %UISPECTEST Verify LabKit UI 2.0 declarative spec contracts. + + methods (Test, TestTags = {'Unit'}) + function test_uiSpec(testCase) + setupLabKitTestPath(); + verify_uiSpec(); + end + end +end + +function verify_uiSpec() +%TEST_UISPEC Verify UI 2.0 spec grammar and GUI-free validation. + + checkCommonSpecShape(); + checkChildrenMustBeCellRows(); + checkFieldKindWhitelist(); + checkPathPanelModes(); + checkPreviewAreaValidation(); + checkCustomBuilderValidation(); + checkDuplicateIdsFailBeforeGuiConstruction(); +end + +function checkCommonSpecShape() + field = labkit.ui.spec.field('gain', 'Gain', ... + 'kind', 'spinner', 'value', 2); + section = labkit.ui.spec.section('settings', 'Settings', {field}); + tab = labkit.ui.spec.tab('setup', 'Setup', {section}); + workspace = labkit.ui.spec.workspace('workspace', 'Preview', { ... + labkit.ui.spec.previewArea('preview', 'Preview')}); + app = labkit.ui.spec.app('probeApp', 'Probe App', ... + 'controlTabs', {tab}, 'workspace', workspace); + + assert(strcmp(app.kind, 'app'), 'App spec should preserve kind.'); + assert(strcmp(app.id, 'probeApp'), 'App spec should preserve id.'); + assert(isstruct(app.props) && iscell(app.children) && isstruct(app.slots), ... + 'Spec should use the common kind/id/props/children/slots shape.'); + assert(iscell(app.props.controlTabs) && isrow(app.props.controlTabs), ... + 'controlTabs should stay a cell row vector for heterogeneous children.'); + assert(strcmp(section.children{1}.id, 'gain'), ... + 'Section children should preserve child specs.'); +end + +function checkChildrenMustBeCellRows() + child = labkit.ui.spec.field('probe', 'Probe'); + assertThrows(@() labkit.ui.spec.section('bad', 'Bad', [child child]), ... + 'labkit:ui:spec:InvalidChildren', ... + 'Struct-array children should be rejected.'); + assertThrows(@() labkit.ui.spec.tab('badTab', 'Bad', {child; child}), ... + 'labkit:ui:spec:InvalidChildren', ... + 'Column-cell children should be rejected.'); +end + +function checkFieldKindWhitelist() + allowed = {'text', 'number', 'spinner', 'dropdown', 'slider', ... + 'checkbox', 'readonly'}; + for k = 1:numel(allowed) + spec = labkit.ui.spec.field(['field' num2str(k)], 'Field', ... + 'kind', allowed{k}); + assert(strcmpi(spec.props.kind, allowed{k}), ... + 'Allowed field kind should be preserved.'); + end + assertThrows(@() labkit.ui.spec.field('radio', 'Radio', ... + 'kind', 'radioGroup'), 'labkit:ui:spec:InvalidFieldKind', ... + 'Primitive or unproven field kinds should stay out of UI 2.0.'); +end + +function checkPathPanelModes() + modes = {'singleFile', 'multiFile', 'folder', 'multiFolder', 'outputFolder'}; + for k = 1:numel(modes) + spec = labkit.ui.spec.pathPanel(['paths' num2str(k)], 'Paths', ... + 'mode', modes{k}); + assert(strcmp(spec.props.mode, modes{k}), ... + 'Allowed pathPanel mode should be preserved.'); + end + singleSelection = labkit.ui.spec.pathPanel('singleSelectPaths', 'Paths', ... + 'mode', 'multiFile', 'selectionMode', 'single'); + assert(strcmp(singleSelection.props.selectionMode, 'single'), ... + 'pathPanel selectionMode should be configurable independently of chooser mode.'); + customChoose = labkit.ui.spec.pathPanel('customChoosePaths', 'Paths', ... + 'mode', 'multiFile', ... + 'chooseLabel', 'Open DTA file(s)', ... + 'clearLabel', 'Clear all'); + assert(strcmp(customChoose.props.chooseLabel, 'Open DTA file(s)'), ... + 'pathPanel chooseLabel should preserve app-authored command wording.'); + assert(strcmp(customChoose.props.clearLabel, 'Clear all'), ... + 'pathPanel clearLabel should preserve app-authored clear command wording.'); + assertThrows(@() labkit.ui.spec.pathPanel('badPaths', 'Paths', ... + 'mode', 'database'), 'labkit:ui:spec:InvalidPathPanelMode', ... + 'Unsupported pathPanel modes should fail validation.'); + assertThrows(@() labkit.ui.spec.pathPanel('badSelection', 'Paths', ... + 'selectionMode', 'range'), ... + 'labkit:ui:spec:InvalidPathPanelSelectionMode', ... + 'Unsupported pathPanel selectionMode should fail validation.'); +end + +function checkPreviewAreaValidation() + pair = labkit.ui.spec.previewArea('pairPreview', 'Pair', ... + 'layout', 'pair', ... + 'axisIds', {'input', 'output'}, ... + 'xLabels', {'Time (s)', 'Time (s)'}, ... + 'yLabels', {'Vf (V)', 'Im (A)'}); + assert(strcmp(pair.props.layout, 'pair'), ... + 'Allowed preview layout should be preserved.'); + assert(strcmp(pair.props.xLabels{1}, 'Time (s)') && ... + strcmp(pair.props.yLabels{2}, 'Im (A)'), ... + 'previewArea should preserve app-authored axis labels.'); + stack = labkit.ui.spec.previewArea('waveforms', 'Waveforms', ... + 'layout', 'stack', 'count', 4); + assert(stack.props.count == 4, ... + 'Stacked preview areas should support axis count.'); + modes = labkit.ui.spec.previewArea('modePreview', 'Mode Preview', ... + 'viewModes', {'Input', 'Output'}, 'onModeChange', @uiSpecModeProbe); + assert(isequal(modes.props.viewModes, {'Input', 'Output'}), ... + 'previewArea should preserve declared viewModes.'); + assertThrows(@() labkit.ui.spec.previewArea('badPreview', 'Bad', ... + 'layout', 'grid'), 'labkit:ui:spec:InvalidPreviewLayout', ... + 'Unsupported preview layouts should fail validation.'); +end + +function checkCustomBuilderValidation() + spec = labkit.ui.spec.custom('customProbe', @uiSpecCustomProbe); + assert(strcmp(spec.kind, 'custom'), ... + 'Named custom builder should produce a custom spec.'); + assertThrows(@() labkit.ui.spec.custom('badCustom', @(varargin) []), ... + 'labkit:ui:spec:InvalidCustomBuilder', ... + 'Anonymous custom builders should be rejected.'); +end + +function checkDuplicateIdsFailBeforeGuiConstruction() + tab = labkit.ui.spec.tab('setup', 'Setup', { ... + labkit.ui.spec.section('sectionOne', 'One', { ... + labkit.ui.spec.field('dup', 'First'), ... + labkit.ui.spec.field('dup', 'Second')})}); + workspace = labkit.ui.spec.workspace('workspace', 'Workspace', {}); + spec = labkit.ui.spec.app('probeApp', 'Probe App', ... + 'controlTabs', {tab}, 'workspace', workspace); + assertThrows(@() labkit.ui.app.create(spec), ... + 'labkit:ui:app:DuplicateId', ... + 'Duplicate ids should fail before GUI construction.'); +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 + +function uiSpecModeProbe(varargin) +end